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, PlacedNote, generators, describe_generator, transforms, describe_transform.
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``, ``PlacedNote``, ``generators``, ``describe_generator``, ``transforms``, ``describe_transform``. 181""" 182 183import subsequence.cadences 184import subsequence.catalogue 185import subsequence.chords 186import subsequence.forms 187import subsequence.roles 188import subsequence.composition 189import subsequence.definitions 190import subsequence.groove 191import subsequence.harmonic_rhythm 192import subsequence.intervals 193import subsequence.melodic_state 194import subsequence.midi_utils 195import subsequence.motifs 196import subsequence.pattern 197import subsequence.progressions 198import subsequence.sequence_utils 199import subsequence.tuning 200 201 202Composition = subsequence.composition.Composition 203Motif = subsequence.motifs.Motif 204Phrase = subsequence.motifs.Phrase 205motif = subsequence.motifs.motif 206sentence = subsequence.motifs.sentence 207period = subsequence.motifs.period 208Cadence = subsequence.cadences.Cadence 209Section = subsequence.forms.Section 210Form = subsequence.forms.Form 211Degree = subsequence.motifs.Degree 212ChordTone = subsequence.motifs.ChordTone 213Approach = subsequence.motifs.Approach 214MotifEvent = subsequence.motifs.MotifEvent 215ControlEvent = subsequence.motifs.ControlEvent 216Progression = subsequence.progressions.Progression 217ChordSpan = subsequence.progressions.ChordSpan 218PitchSet = subsequence.progressions.PitchSet 219progression = subsequence.progressions.progression 220Chord = subsequence.chords.Chord 221Groove = subsequence.groove.Groove 222MelodicState = subsequence.melodic_state.MelodicState 223Tuning = subsequence.tuning.Tuning 224between = subsequence.harmonic_rhythm.between 225parse_chord = subsequence.chords.parse_chord 226register_chord_quality = subsequence.chords.register_chord_quality 227register_scale = subsequence.intervals.register_scale 228scale_notes = subsequence.intervals.scale_notes 229bank_select = subsequence.midi_utils.bank_select 230Definitions = subsequence.definitions.Definitions 231load_definitions = subsequence.definitions.load_definitions 232roles = subsequence.roles 233sieve = subsequence.sequence_utils.sieve 234residual_class = subsequence.sequence_utils.residual_class 235 236# One note read back off a pattern being built, so a caller can type 237# against what PatternBuilder.placed() returns. 238PlacedNote = subsequence.pattern.PlacedNote 239 240# The generator catalogue — what this package offers and what each one takes, 241# as plain data, so a control surface never holds its own list of parameters. 242generators = subsequence.catalogue.generators 243describe_generator = subsequence.catalogue.describe_generator 244 245# The transform catalogue — the same self-description for the verbs that 246# reshape notes already placed, so a surface never holds its own list. 247transforms = subsequence.catalogue.transforms 248describe_transform = subsequence.catalogue.describe_transform
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 pause (self) -> None: 3919 3920 """ 3921 Hold playback where it is, keeping the composition's place. 3922 3923 The clock stops advancing, sounding notes are released, and MIDI Stop 3924 is sent to any hardware following the clock output. :meth:`resume` 3925 continues from the same pulse, beat and bar — where stopping and 3926 playing again would start the piece over. 3927 3928 Bar and cycle counters hold too, so patterns resume mid-phrase rather 3929 than jumping. A note cut short by the pause is not re-struck on 3930 resume; it returns on its pattern's next cycle. 3931 3932 Idempotent and safe to call from any thread. Ignored, with a log line, 3933 when the transport is not ours to hold — under ``clock_follow=True`` or 3934 an active Ableton Link session. 3935 """ 3936 3937 self._sequencer.pause() 3938 3939 def resume (self) -> None: 3940 3941 """ 3942 Continue playback from where :meth:`pause` held it. 3943 3944 Sends MIDI Continue rather than Start, so downstream hardware picks up 3945 where it left off instead of resetting to the top of its own pattern. 3946 Idempotent. 3947 """ 3948 3949 self._sequencer.resume() 3950 3951 @property 3952 def is_paused (self) -> bool: 3953 3954 """True while playback is held by :meth:`pause`.""" 3955 3956 return self._sequencer.paused 3957 3958 def mute (self, name: str) -> None: 3959 3960 """ 3961 Mute a running pattern by name. 3962 3963 The pattern continues to 'run' and increment its cycle count in 3964 the background, but it will not produce any MIDI notes until unmuted. 3965 3966 Parameters: 3967 name: The function name of the pattern to mute. 3968 """ 3969 3970 if name not in self._running_patterns: 3971 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3972 3973 # The performer takes ownership: if a transition's approach window had 3974 # muted this pattern, drop it from that set so the section boundary 3975 # does not silently unmute it ("performer mutes win"). 3976 self._transition_muted.discard(name) 3977 3978 self._running_patterns[name]._muted = True 3979 logger.info(f"Muted pattern: {name}") 3980 3981 def unmute (self, name: str) -> None: 3982 3983 """ 3984 Unmute a previously muted pattern. 3985 """ 3986 3987 if name not in self._running_patterns: 3988 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3989 3990 # Symmetric ownership claim: an explicit unmute means the transition 3991 # machinery should no longer manage this pattern at the boundary. 3992 self._transition_muted.discard(name) 3993 3994 self._running_patterns[name]._muted = False 3995 logger.info(f"Unmuted pattern: {name}") 3996 3997 def unregister (self, name: str) -> None: 3998 3999 """Fully remove a running pattern from rotation. 4000 4001 Unlike ``mute()`` (which keeps the pattern alive but silent), 4002 ``unregister()`` tears the pattern down entirely. It sets 4003 ``pattern._removed = True`` so the sequencer's reschedule loop 4004 skips re-adding it on the next pulse; sends ``note_off`` for any 4005 of the pattern's currently-sounding notes on the primary 4006 destination AND on every mirror destination (so drones and 4007 sustaining notes stop immediately); and removes the entry from 4008 ``_running_patterns`` so it no longer appears in ``live_info()``, 4009 the terminal grid, or any other consumer that enumerates running 4010 patterns. 4011 4012 Already-queued events in the sequencer's event queue play out — 4013 note_offs are paired with their note_ons at queue time, so notes 4014 end at their natural duration; only drones rely on the targeted 4015 ``_stop_pattern_notes`` pass. 4016 4017 Idempotent: silently logs a ``debug`` and returns if the pattern 4018 is already absent. Useful from both the live REPL 4019 (``composition.live()``) and the file watcher 4020 (``composition.watch()``), which calls this for any pattern 4021 removed from the watched file between reloads. 4022 4023 Parameters: 4024 name: Function name of the pattern to remove. 4025 """ 4026 4027 if name not in self._running_patterns: 4028 logger.debug(f"unregister() no-op: pattern '{name}' not running") 4029 return 4030 4031 pattern = self._running_patterns[name] 4032 4033 # Mark for removal first so the reschedule loop sees the flag even if 4034 # it fires concurrently with the note-off pass below. 4035 pattern._removed = True 4036 4037 # Stop sustaining notes (including drones) on every destination this 4038 # pattern outputs to. Fire-and-forget across threads via the event 4039 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 4040 if self._sequencer._event_loop is not None: 4041 asyncio.run_coroutine_threadsafe( 4042 self._sequencer._stop_pattern_notes(pattern), 4043 loop = self._sequencer._event_loop, 4044 ) 4045 4046 def _finalise_removal () -> None: 4047 self._running_patterns.pop(name, None) 4048 4049 # Forget any pending (not-yet-graduated) declaration too, so a 4050 # later live reload cannot resurrect the pattern. 4051 self._pending_patterns = [ 4052 pending for pending in self._pending_patterns 4053 if pending.builder_fn.__name__ != name 4054 ] 4055 4056 logger.info(f"Unregistered pattern: {name}") 4057 4058 # The running-patterns dict is iterated by the display, web UI, and 4059 # reschedule loop on the event loop thread — mutate it there when this 4060 # call arrives from another thread (e.g. the live TCP server). 4061 loop = self._sequencer._event_loop 4062 4063 try: 4064 on_loop = loop is not None and asyncio.get_running_loop() is loop 4065 except RuntimeError: 4066 on_loop = False 4067 4068 if loop is not None and loop.is_running() and not on_loop: 4069 loop.call_soon_threadsafe(_finalise_removal) 4070 else: 4071 _finalise_removal() 4072 4073 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 4074 4075 """ 4076 Add a mirror destination to a running pattern. 4077 4078 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 4079 event the pattern emits will also be sent to ``(device, channel)``, 4080 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 4081 — calling with the same destination twice does not double-fan; calling 4082 again with a different ``drum_note_map`` re-points it in place. 4083 4084 Parameters: 4085 name: Function name of the pattern to mirror. 4086 device: Output device index (the integer returned from 4087 ``midi_output()``; 0 = primary device). 4088 channel: MIDI channel using this composition's numbering convention 4089 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 4090 drum_note_map: Optional per-destination drum map. When set, mirrored 4091 drum hits are re-resolved by name through it, so a named voice 4092 lands on this device's own note number — see the README 4093 "MIDI mirroring" section. 4094 4095 Bandwidth: each mirror adds another full copy of the pattern's events. 4096 See the README "MIDI mirroring" section for the tradeoffs. 4097 """ 4098 4099 if name not in self._running_patterns: 4100 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4101 4102 resolved_channel = self._resolve_channel(channel) 4103 prefix = (device, resolved_channel) 4104 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 4105 4106 pattern = self._running_patterns[name] 4107 4108 # Mirror-to-self check: comparing the (device, channel) prefix against the 4109 # live pattern's resolved destination. Unlike the decorator path this is 4110 # always concrete. 4111 if prefix == (pattern.device, pattern.channel): 4112 logger.warning( 4113 f"Mirror destination {prefix} matches '{name}'s primary destination " 4114 f"— every event will double-fire on this (device, channel). This is almost " 4115 f"certainly unintended." 4116 ) 4117 4118 # Idempotent on (device, channel): replace any existing entry for the same 4119 # destination (so its map can be re-pointed), else append. 4120 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 4121 if existing_index is None: 4122 pattern.mirrors.append(entry) 4123 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 4124 elif pattern.mirrors[existing_index] != entry: 4125 pattern.mirrors[existing_index] = entry 4126 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 4127 else: 4128 logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}") 4129 4130 def unmirror (self, name: str, device: int, channel: int) -> None: 4131 4132 """ 4133 Remove a single mirror destination from a running pattern. 4134 4135 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 4136 ignored. Idempotent: silently does nothing if the destination is not 4137 currently mirrored. The change applies on the next cycle rebuild. 4138 """ 4139 4140 if name not in self._running_patterns: 4141 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4142 4143 resolved_channel = self._resolve_channel(channel) 4144 prefix = (device, resolved_channel) 4145 4146 pattern = self._running_patterns[name] 4147 4148 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 4149 if len(filtered) != len(pattern.mirrors): 4150 pattern.mirrors[:] = filtered 4151 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 4152 else: 4153 logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors") 4154 4155 def unmirror_all (self, name: str) -> None: 4156 4157 """ 4158 Remove every mirror destination from a running pattern. 4159 """ 4160 4161 if name not in self._running_patterns: 4162 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4163 4164 pattern = self._running_patterns[name] 4165 4166 if pattern.mirrors: 4167 pattern.mirrors.clear() 4168 logger.info(f"All mirrors cleared on pattern: {name}") 4169 4170 def tweak (self, name: str, **kwargs: typing.Any) -> None: 4171 4172 """Override parameters for a running pattern. 4173 4174 Values set here are available inside the pattern's builder 4175 function via ``p.param()``. They persist across rebuilds 4176 until explicitly changed or cleared. Changes take effect 4177 on the next rebuild cycle. 4178 4179 Parameters: 4180 name: The function name of the pattern. 4181 ``**kwargs``: Parameter names and their new values. 4182 4183 Example (from the live REPL):: 4184 4185 composition.tweak("bass", pitches=[48, 52, 55, 60]) 4186 """ 4187 4188 if name not in self._running_patterns: 4189 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4190 4191 self._running_patterns[name]._tweaks.update(kwargs) 4192 logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}") 4193 4194 def clear_tweak (self, name: str, *param_names: str) -> None: 4195 4196 """Remove tweaked parameters from a running pattern. 4197 4198 If no parameter names are given, all tweaks for the pattern 4199 are cleared and every ``p.param()`` call reverts to its 4200 default. 4201 4202 Parameters: 4203 name: The function name of the pattern. 4204 *param_names: Specific parameter names to clear. If 4205 omitted, all tweaks are removed. 4206 """ 4207 4208 if name not in self._running_patterns: 4209 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4210 4211 if not param_names: 4212 self._running_patterns[name]._tweaks.clear() 4213 logger.info(f"Cleared all tweaks for pattern '{name}'") 4214 else: 4215 for param_name in param_names: 4216 self._running_patterns[name]._tweaks.pop(param_name, None) 4217 logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}") 4218 4219 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 4220 4221 """Return a copy of the current tweaks for a running pattern. 4222 4223 Parameters: 4224 name: The function name of the pattern. 4225 """ 4226 4227 if name not in self._running_patterns: 4228 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4229 4230 return dict(self._running_patterns[name]._tweaks) 4231 4232 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 4233 4234 """ 4235 Register a custom function to run on a repeating beat-based cycle. 4236 4237 Subsequence automatically runs synchronous functions in a thread pool 4238 so they don't block the timing-critical MIDI clock. Async functions 4239 are run directly on the event loop. 4240 4241 Parameters: 4242 fn: The function to call. 4243 cycle_beats: How often to call it (e.g., 4 = every bar). 4244 reschedule_lookahead: How far in advance to schedule the next call. 4245 wait_for_initial: If True, run the function once during startup 4246 and wait for it to complete before playback begins. This 4247 ensures ``composition.data`` is populated before patterns 4248 first build. Implies ``defer=True`` for the repeating 4249 schedule. 4250 defer: If True, skip the pulse-0 fire and defer the first 4251 repeating call to just before the second cycle boundary. 4252 4253 Raises: 4254 RuntimeError: If called after ``play()`` has started — scheduled 4255 tasks register at startup, so a late registration would be 4256 silently ignored otherwise. 4257 """ 4258 4259 if self._sequencer.running: 4260 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 4261 4262 self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer)) 4263 4264 def form ( 4265 self, 4266 sections: typing.Union[ 4267 "subsequence.forms.Form", 4268 typing.List[typing.Any], 4269 typing.Iterator[typing.Tuple[str, int]], 4270 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 4271 ], 4272 loop: bool = False, 4273 start: typing.Optional[str] = None, 4274 at_end: str = "stop", 4275 key: typing.Optional[str] = None, 4276 scale: typing.Optional[str] = None, 4277 ) -> None: 4278 4279 """ 4280 Define the structure (sections) of the composition. 4281 4282 You can define form in four ways: 4283 4284 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of 4285 :class:`~subsequence.forms.Section` values — the payload home 4286 (energy, key per section); editable, navigable. 4287 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples 4288 or Sections (lists coerce — they are the same form). 4289 3. **Graph (Dict)**: dynamic transitions based on weights. 4290 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. 4291 4292 Form-value and list forms are **navigable**: ``form_jump()`` and 4293 ``form_next()`` work on them (the jump lands on the next occurrence 4294 of the name, wrapping). 4295 4296 Re-binding ``form()`` during playback takes effect at the next bar — 4297 the clock reads the current form state on every bar, so the new form 4298 advances from there (its first section plays from its first bar). 4299 4300 Parameters: 4301 sections: The form definition (Form, List, Dict, or Generator). 4302 loop: Sugar for ``at_end="loop"``. 4303 start: The section to start with (Graph mode only). 4304 at_end: What happens when a sequence form runs out — 4305 ``"stop"`` (the form finishes and patterns see no section; 4306 default), ``"hold"`` (the final section repeats until 4307 navigated away from), or ``"loop"`` (start over). Graphs 4308 end via their terminal sections instead. 4309 key: A form-level key — the **form tier** of the key-source 4310 chain (``Section.key`` overrides it; it overrides the 4311 composition key). Re-anchors key-relative content for the 4312 whole form. When *sections* is a ``Form`` value carrying its 4313 own ``key``, that value is used unless this argument overrides. 4314 scale: A form-level scale/mode, paired with ``key``. 4315 4316 Example: 4317 ```python 4318 # A simple pop structure 4319 comp.form([ 4320 ("verse", 8), 4321 ("chorus", 8), 4322 ("verse", 8), 4323 ("chorus", 16) 4324 ]) 4325 4326 # The same structure with payloads, held open at the end 4327 S = subsequence.Section 4328 comp.form(subsequence.Form([ 4329 S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), 4330 ]), at_end="hold") 4331 ``` 4332 """ 4333 4334 # Seed FormState at form() time (per-call salt) so build-time walks — 4335 # the frozen clones form_freeze will take — are deterministic without 4336 # play(); the play-time stream is re-dealt name-keyed in _run(). 4337 self._form_count += 1 4338 4339 self._form_state = subsequence.form_state.FormState( 4340 sections, 4341 loop = loop, 4342 start = start, 4343 rng = self._stream(f"form:{self._form_count}"), 4344 at_end = at_end, 4345 ) 4346 4347 # A Form value carries energy payloads — that counts as an energy 4348 # source for the min_energy registration check in _run(). 4349 self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( 4350 isinstance(sections, list) and any(isinstance(element, subsequence.forms.Section) for element in sections) 4351 ) 4352 4353 # Form-tier key/scale: an explicit argument wins; otherwise a Form 4354 # value's own key/scale seeds the tier. Re-binding the form drops any 4355 # stale per-section resolution cache. 4356 if isinstance(sections, subsequence.forms.Form): 4357 self._form_key = key if key is not None else sections.key 4358 self._form_scale = scale if scale is not None else sections.scale 4359 else: 4360 self._form_key = key 4361 self._form_scale = scale 4362 4363 self._resolved_section_cache = {} 4364 4365 def form_freeze (self, sections: typing.Optional[int] = None) -> "subsequence.forms.Form": 4366 4367 """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. 4368 4369 Walks a **clone** of the live form state — the same RNG state, so the 4370 frozen path is exactly the path the live graph would have played — 4371 and returns it as a Form value: inspect it, edit it 4372 (``path.replace(3, bars=16)``), and rebind it with 4373 ``composition.form(path, at_end=...)``. The live form state is 4374 untouched (rebinding replaces it). 4375 4376 Parameters: 4377 sections: Number of sections to freeze. Without it, the walk 4378 runs until a terminal section; a graph with no terminal 4379 sections requires ``sections=`` explicitly. 4380 4381 Raises: 4382 ValueError: If no graph form is bound (a list form is already a 4383 frozen sequence), the form has already finished, or the walk 4384 cannot terminate. 4385 4386 Example:: 4387 4388 composition.form({...}, start="intro") 4389 path = composition.form_freeze() # the walk, frozen 4390 composition.form(path, at_end="stop") # rebind the editable value 4391 """ 4392 4393 fs = self._form_state 4394 4395 if fs is None or fs._graph is None or fs._section_bars is None: 4396 raise ValueError( 4397 "form_freeze() freezes a graph form's walk — call form() with a dict first " 4398 "(a list form is already a frozen sequence)" 4399 ) 4400 4401 if fs._current is None: 4402 raise ValueError("the form has already finished — nothing left to freeze") 4403 4404 if sections is not None and sections < 1: 4405 raise ValueError("sections must be at least 1") 4406 4407 if sections is None and not fs._terminal_sections: 4408 raise ValueError( 4409 "this graph has no terminal section, so the walk would never end — " 4410 "pass sections=n to bound it" 4411 ) 4412 4413 # Clone the RNG state: the frozen walk reproduces the live form's 4414 # future draws without consuming them. 4415 rng = random.Random() 4416 rng.setstate(fs._rng.getstate()) 4417 4418 walked = [fs._current] 4419 next_name = fs._next_section_name # already decided by the live state 4420 4421 while next_name is not None: 4422 if sections is not None and len(walked) >= sections: 4423 break 4424 if sections is None and len(walked) >= 10000: 4425 raise ValueError( 4426 "form_freeze() walked 10000 sections without reaching a terminal — " 4427 "the terminals look unreachable; pass sections=n to bound the walk" 4428 ) 4429 4430 walked.append(subsequence.forms.Section(name = next_name, bars = fs._section_bars[next_name])) 4431 next_name = None if next_name in fs._terminal_sections else fs._graph.choose_next(next_name, rng) 4432 4433 # Carry the form-tier key/scale onto the frozen value so a freeze → 4434 # rebind round-trip is lossless (an explicit form(key=) on rebind 4435 # still overrides). 4436 return subsequence.forms.Form(walked, key = self._form_key, scale = self._form_scale) 4437 4438 def energy (self, energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]]) -> None: 4439 4440 """Set per-section energy — the arranging dial, as one plain dict. 4441 4442 ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is 4443 the section's level; a ``(start, end)`` tuple interpolates across the 4444 section (a build). Patterns read ``p.energy`` (0.5 when nothing is 4445 configured) and gate themselves, or declare ``min_energy=`` on 4446 ``pattern()`` for automatic muting. 4447 4448 The dict **overrides** any energy payload carried by bound 4449 :class:`~subsequence.forms.Section` values — it is the later, 4450 performance-level dial. Re-calling replaces the whole mapping 4451 (idempotent, live-reload friendly). 4452 4453 Example:: 4454 4455 composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) 4456 """ 4457 4458 validated: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 4459 4460 for name, value in energies.items(): 4461 if isinstance(value, tuple): 4462 if len(value) != 2: 4463 raise ValueError(f"energy ramp for {name!r} must be (start, end), got {value!r}") 4464 start_level, end_level = float(value[0]), float(value[1]) 4465 for level in (start_level, end_level): 4466 if not 0.0 <= level <= 1.0: 4467 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4468 validated[name] = (start_level, end_level) 4469 else: 4470 level = float(value) 4471 if not 0.0 <= level <= 1.0: 4472 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4473 validated[name] = level 4474 4475 self._energy_map = validated 4476 4477 def _current_energy (self, info: typing.Optional[subsequence.form_state.SectionInfo]) -> float: 4478 4479 """Resolve the energy for a section snapshot. 4480 4481 Priority: the ``energy()`` dict (ramps interpolate by section 4482 progress) > the bound Section payload > 0.5. 4483 """ 4484 4485 if info is None: 4486 return 0.5 4487 4488 spec = self._energy_map.get(info.name) 4489 4490 if spec is None: 4491 return info.energy 4492 4493 if isinstance(spec, tuple): 4494 start_level, end_level = spec 4495 4496 # A build reaches its declared end ON the final bar, so the ramp spans 4497 # bar 0 → bar (bars-1). (info.progress is bar/bars, which would top 4498 # out one bar short and never deliver end.) A one-bar section sits at 4499 # the destination level. 4500 span = info.bars - 1 4501 fraction = info.bar / span if span > 0 else 1.0 4502 4503 return start_level + (end_level - start_level) * fraction 4504 4505 return spec 4506 4507 def on_section (self, callback: typing.Callable[..., typing.Any]) -> None: 4508 4509 """Register a callback fired on every section change. 4510 4511 The callback receives the new :class:`~subsequence.form_state.SectionInfo` 4512 (or ``None`` when the form finishes). It fires from the form clock, 4513 one lookahead-beat **early** — in time to affect the new section's 4514 first patterns — and once at play start for the opening section. 4515 4516 Example:: 4517 4518 composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) 4519 """ 4520 4521 self.on_event("section", callback) 4522 4523 def transition ( 4524 self, 4525 before: str, 4526 fill: typing.Optional[typing.Any] = None, 4527 channel: typing.Optional[int] = None, 4528 beat: float = 0.0, 4529 mute: typing.Optional[typing.List[str]] = None, 4530 beats: typing.Optional[float] = None, 4531 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4532 device: subsequence.midi_utils.DeviceId = None, 4533 ) -> None: 4534 4535 """Declare boundary material — the automatic fill or mute, one line. 4536 4537 ``before`` names the incoming section (``"chorus"``), or ``"*"`` for 4538 any *different* section (repeats don't fire it). Two actions, 4539 combinable: 4540 4541 - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last 4542 bar before the boundary, starting at ``beat`` of that bar. Drum 4543 names resolve through ``drum_note_map=`` if given, otherwise the 4544 map is borrowed from a registered pattern on the same channel. 4545 - ``mute=`` (+ ``beats=``): pattern names muted over the approach 4546 and unmuted at the boundary. Muting is **bar-granular** (the 4547 existing rule), so ``beats`` rounds up to whole bars. Performer 4548 mutes win: a pattern you muted yourself stays muted. 4549 4550 Transitions stack — call once per rule. Registration is additive 4551 and idempotent per identical rule. 4552 4553 Example:: 4554 4555 composition.transition(before="*", fill=FILL, channel=10, beat=2.0) 4556 composition.transition(before="drop", mute=["pads"], beats=4) 4557 """ 4558 4559 if fill is None and mute is None: 4560 raise ValueError("transition() needs fill= and/or mute= — it declares what happens at the boundary") 4561 4562 if fill is not None: 4563 if channel is None: 4564 raise ValueError("transition(fill=) needs channel= — the fill must land somewhere") 4565 if not hasattr(fill, "events") or not hasattr(fill, "length"): 4566 raise TypeError(f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}") 4567 4568 if mute is not None and beats is None: 4569 beats = float(self.time_signature[0]) # one bar by default 4570 4571 rule = _Transition( 4572 before = before, 4573 fill = fill, 4574 channel = self._resolve_channel(channel) if channel is not None else None, 4575 beat = float(beat), 4576 mute = list(mute) if mute is not None else None, 4577 beats = beats, 4578 drum_note_map = drum_note_map, 4579 device = device, # resolved at fire time — names aren't known until play() 4580 ) 4581 4582 if rule not in self._transitions: 4583 self._transitions.append(rule) 4584 4585 def _transition_drum_map (self, channel: typing.Optional[int]) -> typing.Optional[typing.Dict[str, int]]: 4586 4587 """Borrow a drum map from a registered pattern on the same channel.""" 4588 4589 if channel is None: 4590 return None 4591 4592 for pending in self._pending_patterns: 4593 if pending.channel == channel and pending.drum_note_map: 4594 return pending.drum_note_map 4595 4596 for running in self._running_patterns.values(): 4597 candidate = getattr(running, "_drum_note_map", None) 4598 if running.channel == channel and candidate: 4599 return typing.cast(typing.Dict[str, int], candidate) 4600 4601 return None 4602 4603 def _fire_fill (self, rule: _Transition, start_pulse: int) -> None: 4604 4605 """Build a transition fill as a one-shot pattern and schedule it.""" 4606 4607 assert rule.fill is not None and rule.channel is not None 4608 4609 drum_map = rule.drum_note_map if rule.drum_note_map is not None else self._transition_drum_map(rule.channel) 4610 4611 pattern = subsequence.pattern.Pattern( 4612 channel = rule.channel, 4613 length = float(rule.fill.length), 4614 device = self._resolve_device_id(rule.device), 4615 ) 4616 4617 harmony_view: typing.Optional[HarmonyView] = None 4618 if not self._harmony_horizon.is_empty: 4619 harmony_view = HarmonyView(self._harmony_horizon, start_pulse / self._sequencer.pulses_per_beat) 4620 4621 # The fill sounds in the outgoing section's final bar, so a degree- 4622 # bearing fill resolves against THAT section's effective key/scale — 4623 # previously it took the composition key, ignoring the section. 4624 fill_section = self._form_state.get_section_info() if self._form_state else None 4625 fill_key, fill_scale = self._effective_key_scale(fill_section) 4626 4627 builder = subsequence.pattern_builder.PatternBuilder( 4628 pattern = pattern, 4629 cycle = 0, 4630 drum_note_map = drum_map, 4631 section = fill_section, 4632 bar = self._builder_bar, 4633 conductor = self.conductor, 4634 rng = self._stream(f"transition:{rule.before}:{start_pulse}") or random.Random(), 4635 tweaks = {}, 4636 default_grid = 16, 4637 data = self.data, 4638 key = fill_key, 4639 scale = fill_scale, 4640 time_signature = self.time_signature, 4641 harmony = harmony_view, 4642 ) 4643 4644 try: 4645 builder.motif(rule.fill) 4646 except Exception: 4647 logger.exception("transition fill failed to build — the boundary plays without it") 4648 return 4649 4650 self._schedule_one_shot(pattern, start_pulse) 4651 4652 def _check_transitions (self, boundary_pulse: int, section_changed: bool) -> None: 4653 4654 """The form clock's boundary hook: fire fills, manage approach mutes. 4655 4656 Called once per bar (lookahead-early, with the bar-line pulse). 4657 Fill rules fire when the current bar is the section's last before a 4658 matching boundary; mute rules close over the approach window 4659 (rounded up to whole bars — muting is bar-granular) and reopen at 4660 the boundary. Performer mutes are never touched. 4661 """ 4662 4663 if section_changed and self._transition_muted: 4664 # The boundary arrived — restore only what we muted ourselves. 4665 for name in self._transition_muted: 4666 running = self._running_patterns.get(name) 4667 if running is not None: 4668 running._muted = False 4669 self._transition_muted.clear() 4670 4671 if not self._transitions or self._form_state is None: 4672 return 4673 4674 info = self._form_state.get_section_info() 4675 4676 if info is None or info.next_section is None: 4677 return 4678 4679 bar_beats = float(self.time_signature[0]) 4680 bars_remaining = info.bars - info.bar 4681 4682 for rule in self._transitions: 4683 4684 if rule.before == "*": 4685 if info.next_section == info.name: 4686 continue # a repeat is not a boundary 4687 elif info.next_section != rule.before: 4688 continue 4689 4690 if rule.fill is not None and bars_remaining == 1: 4691 self._fire_fill(rule, boundary_pulse + int(round(rule.beat * self._sequencer.pulses_per_beat))) 4692 4693 if rule.mute: 4694 window_beats = rule.beats if rule.beats is not None else bar_beats 4695 window_bars = max(1, int((window_beats + bar_beats - 1e-9) // bar_beats)) 4696 4697 if bars_remaining <= window_bars: 4698 for name in rule.mute: 4699 running = self._running_patterns.get(name) 4700 if running is None or name in self._transition_muted: 4701 continue 4702 if running._muted: 4703 continue # the performer's mute — not ours to manage 4704 running._muted = True 4705 self._transition_muted.add(name) 4706 4707 @staticmethod 4708 def _resolve_length ( 4709 beats: typing.Optional[float], 4710 bars: typing.Optional[float], 4711 steps: typing.Optional[float], 4712 step_duration: typing.Optional[float], 4713 default: float = 4.0, 4714 beats_per_bar: int = 4, 4715 ) -> typing.Tuple[float, int]: 4716 4717 """ 4718 Resolve the beat_length and default_grid from the duration parameters. 4719 4720 Two modes: 4721 4722 - **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``. 4723 ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats. 4724 - **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``. 4725 ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats. 4726 4727 Constraints: 4728 4729 - ``beats`` and ``bars`` are mutually exclusive. 4730 - ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``. 4731 - ``steps`` cannot be combined with ``beats`` or ``bars``. 4732 4733 Returns: 4734 (beat_length, default_grid) — beat_length in beats (quarter notes); 4735 default_grid the number of grid steps (16th-notes in beat mode, or the 4736 explicit ``steps`` value directly in step mode). 4737 """ 4738 4739 if beats is not None and bars is not None: 4740 raise ValueError("Specify only one of beats= or bars=") 4741 4742 if steps is not None and (beats is not None or bars is not None): 4743 raise ValueError("steps= cannot be combined with beats= or bars=") 4744 4745 if step_duration is not None and steps is None: 4746 raise ValueError("step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)") 4747 4748 if steps is not None: 4749 if step_duration is None: 4750 raise ValueError("steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)") 4751 return steps * step_duration, int(steps) 4752 4753 if bars is not None: 4754 raw = bars * beats_per_bar 4755 elif beats is not None: 4756 raw = beats 4757 else: 4758 raw = default 4759 4760 return raw, round(raw / subsequence.constants.durations.SIXTEENTH) 4761 4762 def pattern ( 4763 self, 4764 channel: int, 4765 beats: typing.Optional[float] = None, 4766 bars: typing.Optional[float] = None, 4767 steps: typing.Optional[float] = None, 4768 step_duration: typing.Optional[float] = None, 4769 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4770 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4771 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4772 reschedule_lookahead: float = 1, 4773 voice_leading: bool = False, 4774 device: subsequence.midi_utils.DeviceId = None, 4775 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4776 min_energy: typing.Optional[float] = None, 4777 ) -> typing.Callable: 4778 4779 """ 4780 Register a function as a repeating MIDI pattern. 4781 4782 The decorated function will be called once per cycle to 'rebuild' its 4783 content. This allows for generative logic that evolves over time. 4784 4785 Two ways to specify pattern length: 4786 4787 - **Duration mode** (default): use ``beats=`` or ``bars=``. 4788 The grid defaults to sixteenth-note resolution. 4789 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 4790 The grid equals the step count, so ``p.hit_steps()`` indices map 4791 directly to steps. 4792 4793 Parameters: 4794 channel: MIDI channel. By default uses 1-based numbering (1-16). 4795 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 4796 0-based numbering (0-15), matching the raw MIDI protocol, instead. 4797 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 4798 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 4799 steps: Step count for step mode. Requires ``step_duration=``. 4800 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 4801 Requires ``steps=``. 4802 drum_note_map: Optional mapping for drum instruments. 4803 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4804 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 4805 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 4806 14-bit parameter numbers (0–16383). Enables string-based names 4807 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 4808 device-specific dictionary (e.g. Sequential Take 5's 4809 ``Osc1FreqFine`` → 9). 4810 reschedule_lookahead: Beats in advance to compute the next cycle. 4811 voice_leading: If True, chords in this pattern will automatically 4812 use inversions that minimize voice movement. 4813 mirrors: Optional list of additional ``(device, channel)`` destinations 4814 to duplicate every event from this pattern onto. Notes, CCs, pitch 4815 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 4816 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 4817 ``device`` is the integer index returned by ``midi_output()`` (0 = 4818 primary). ``channel`` follows this composition's channel-numbering 4819 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 4820 min_energy: Automatic energy gating — the pattern is silent while 4821 the current section's energy (``composition.energy()`` dict, 4822 or the bound Section payload) is below this threshold. 4823 Composes with ``mute()``: a performer mute always wins. 4824 4825 Example: 4826 ```python 4827 @comp.pattern(channel=1, beats=4) 4828 def chords (p): 4829 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 4830 4831 @comp.pattern(channel=1, bars=2) 4832 def long_phrase (p): 4833 ... 4834 4835 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 4836 def riff (p): 4837 p.sequence(steps=[0, 1, 3, 5], pitches=60) 4838 ``` 4839 """ 4840 4841 channel = self._resolve_channel(channel) 4842 4843 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4844 4845 # Resolve device string name to index if possible now; otherwise store 4846 # the raw DeviceId and resolve it in _run() once all devices are open. 4847 resolved_device: subsequence.midi_utils.DeviceId = device 4848 4849 # Mirror-to-self check is only reliable when the primary device is a 4850 # concrete integer at decoration time. ``None`` resolves to device 0 4851 # downstream, so we treat it as 0 here too. Strings are deferred to 4852 # ``_run()`` and we skip the check for them. 4853 primary: typing.Optional[typing.Tuple[int, int]] 4854 if isinstance(resolved_device, str): 4855 primary = None 4856 else: 4857 primary = (resolved_device if resolved_device is not None else 0, channel) 4858 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4859 4860 def decorator (fn: typing.Callable) -> typing.Callable: 4861 4862 """ 4863 Wrap the builder function and register it as a pending pattern. 4864 During live sessions, hot-swap an existing pattern's builder instead. 4865 """ 4866 4867 # Record this declaration so the live-reload deletion diff knows the 4868 # pattern is still present in the source (see _apply_source_async). 4869 self._declared_names.add(fn.__name__) 4870 4871 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 4872 if self._is_live and fn.__name__ in self._running_patterns: 4873 running = self._running_patterns[fn.__name__] 4874 running._builder_fn = fn 4875 running._wants_chord = _fn_has_parameter(fn, "chord") 4876 logger.info(f"Hot-swapped pattern: {fn.__name__}") 4877 return fn 4878 4879 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 4880 # duplicate means two scheduled copies sharing one stream with 4881 # only one reachable by name. Warn loudly at registration. 4882 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 4883 logger.warning( 4884 f"Duplicate pattern name '{fn.__name__}': both copies will be " 4885 f"scheduled, they share one seeded stream, and only one is " 4886 f"reachable by name — rename one of them." 4887 ) 4888 4889 pending = _PendingPattern( 4890 builder_fn = fn, 4891 channel = channel, # already resolved to 0-indexed 4892 length = beat_length, 4893 default_grid = default_grid, 4894 drum_note_map = drum_note_map, 4895 cc_name_map = cc_name_map, 4896 nrpn_name_map = nrpn_name_map, 4897 reschedule_lookahead = reschedule_lookahead, 4898 voice_leading = voice_leading, 4899 # For int/None: resolve immediately. For str: store 0 as 4900 # placeholder; _resolve_pending_devices() fixes it in _run(). 4901 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 4902 raw_device = resolved_device, 4903 mirrors = resolved_mirrors, 4904 min_energy = min_energy, 4905 ) 4906 4907 self._pending_patterns.append(pending) 4908 4909 return fn 4910 4911 return decorator 4912 4913 def layer ( 4914 self, 4915 *builder_fns: typing.Callable, 4916 channel: int, 4917 beats: typing.Optional[float] = None, 4918 bars: typing.Optional[float] = None, 4919 steps: typing.Optional[float] = None, 4920 step_duration: typing.Optional[float] = None, 4921 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4922 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4923 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4924 reschedule_lookahead: float = 1, 4925 voice_leading: bool = False, 4926 device: subsequence.midi_utils.DeviceId = None, 4927 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4928 ) -> None: 4929 4930 """ 4931 Combine multiple functions into a single MIDI pattern. 4932 4933 This is useful for composing complex patterns out of reusable 4934 building blocks (e.g., a 'kick' function and a 'snare' function). 4935 4936 See ``pattern()`` for the full description of ``beats``, ``bars``, 4937 ``steps``, and ``step_duration``. 4938 4939 Parameters: 4940 builder_fns: One or more pattern builder functions. 4941 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4942 beats: Duration in beats (quarter notes). 4943 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4944 steps: Step count for step mode. Requires ``step_duration=``. 4945 step_duration: Duration of one step in beats. Requires ``steps=``. 4946 drum_note_map: Optional mapping for drum instruments. 4947 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4948 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 4949 parameter numbers. 4950 reschedule_lookahead: Beats in advance to compute the next cycle. 4951 voice_leading: If True, chords use smooth voice leading. 4952 mirrors: Optional list of additional ``(device, channel)`` destinations 4953 to duplicate every event onto. See ``pattern()`` for details. 4954 """ 4955 4956 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4957 4958 # Resolve channel up-front so the mirror-to-self check has the canonical 4959 # primary form to compare against. 4960 resolved_channel = self._resolve_channel(channel) 4961 4962 # See pattern() for the same comment about None / str handling. 4963 primary: typing.Optional[typing.Tuple[int, int]] 4964 if isinstance(device, str): 4965 primary = None 4966 else: 4967 primary = (device if device is not None else 0, resolved_channel) 4968 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4969 4970 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 4971 4972 if wants_chord: 4973 4974 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 4975 4976 for fn in builder_fns: 4977 if _fn_has_parameter(fn, "chord"): 4978 fn(p, chord) 4979 else: 4980 fn(p) 4981 4982 else: 4983 4984 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 4985 4986 for fn in builder_fns: 4987 fn(p) 4988 4989 # Give the merged builder a stable, unique name derived from its 4990 # components so multiple layer() calls don't all register under 4991 # "merged_builder" and collide in _running_patterns (which made 4992 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 4993 # appear in a Python identifier, so this never clashes with a real 4994 # pattern function's name. 4995 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 4996 merged_name = base_name 4997 suffix = 2 4998 4999 # Two layers with the same components (e.g. on different saves of a 5000 # live file) must map to the same names pass-over-pass, while two 5001 # DIFFERENT layers sharing components in one pass must not collide. 5002 while merged_name in self._declared_names: 5003 merged_name = f"{base_name}#{suffix}" 5004 suffix += 1 5005 5006 merged_builder.__name__ = merged_name 5007 5008 # Record the declaration for the live-reload deletion diff, and hot-swap 5009 # in place when this layer is already running so a reload picks up edits 5010 # to the component functions without losing the pattern's cycle count, 5011 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 5012 self._declared_names.add(merged_builder.__name__) 5013 5014 if self._is_live and merged_builder.__name__ in self._running_patterns: 5015 running = self._running_patterns[merged_builder.__name__] 5016 running._builder_fn = merged_builder 5017 running._wants_chord = wants_chord 5018 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 5019 return 5020 5021 pending = _PendingPattern( 5022 builder_fn = merged_builder, 5023 channel = resolved_channel, # already resolved to 0-indexed above 5024 length = beat_length, 5025 default_grid = default_grid, 5026 drum_note_map = drum_note_map, 5027 cc_name_map = cc_name_map, 5028 nrpn_name_map = nrpn_name_map, 5029 reschedule_lookahead = reschedule_lookahead, 5030 voice_leading = voice_leading, 5031 mirrors = resolved_mirrors, 5032 device = 0 if (device is None or isinstance(device, str)) else device, 5033 raw_device = device, 5034 ) 5035 5036 self._pending_patterns.append(pending) 5037 5038 def chords ( 5039 self, 5040 *, 5041 channel: int, 5042 progression: subsequence.progressions.ProgressionSource, 5043 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 5044 bars: typing.Optional[float] = None, 5045 beats: typing.Optional[float] = None, 5046 voicing: subsequence.progressions.VoicingSpec = (3, 4), 5047 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 5048 detached: typing.Optional[float] = None, 5049 root: int = 60, 5050 key: typing.Optional[str] = None, 5051 seed: typing.Optional[int] = None, 5052 device: subsequence.midi_utils.DeviceId = None, 5053 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5054 ) -> subsequence.progressions.Progression: 5055 5056 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 5057 5058 The one-call form of ``p.progression()`` — it registers a pattern on 5059 *channel* that plays *progression* across *bars* (or *beats*), each chord 5060 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 5061 the chords change). It needs no ``composition.harmony()`` call and, with an 5062 explicit chord list or a ``key=``, no composition key either — so a 5063 drums-plus-one-chord-part sketch stays simple. 5064 5065 The progression is realised once, up front, and the same timeline plays every 5066 cycle (a stable phrase). That timeline is returned so you can see exactly what 5067 was chosen — ``print(comp.chords(...))``. 5068 5069 Parameters: 5070 channel: MIDI channel for the chord part. 5071 progression: A chord-graph style name to generate from, or an explicit list 5072 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 5073 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 5074 or ``between(low, high, step=...)``. See ``p.progression()``. 5075 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 5076 composition's time signature. 5077 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 5078 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 5079 detached: Beats of silence before each next chord (``duration = length - detached``). 5080 root: MIDI root the voicings are centred on (e.g. 48 = C3). 5081 key: Key for a generated progression; defaults to the composition key. 5082 seed: Seed for the (otherwise fixed) realisation; defaults to the 5083 composition seed, so the part is reproducible. 5084 device: Optional output-device override. 5085 mirrors: Optional additional ``(device, channel)`` destinations. 5086 5087 Returns: 5088 The realised :class:`~subsequence.progressions.Progression`. 5089 """ 5090 5091 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5092 resolved_channel = self._resolve_channel(channel) 5093 resolved_key = key if key is not None else self.key 5094 5095 rng = random.Random(seed if seed is not None else self._seed) 5096 timeline = subsequence.progressions.realize( 5097 source = progression, 5098 harmonic_rhythm = harmonic_rhythm, 5099 key = resolved_key, 5100 length = beat_length, 5101 rng = rng, 5102 scale = self.scale or "ionian", 5103 ) 5104 5105 captured_root = root 5106 captured_velocity = velocity 5107 captured_detached = detached 5108 captured_voicing = voicing 5109 5110 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5111 5112 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 5113 5114 for chord, start, length in timeline: 5115 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 5116 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 5117 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 5118 5119 # Unique, stable name so multiple chord parts don't collide in 5120 # _running_patterns — including two parts on the SAME channel, which 5121 # get a deterministic #2/#3 suffix in declaration order. 5122 base_name = f"chords@ch{resolved_channel}" 5123 chords_name = base_name 5124 suffix = 2 5125 5126 while chords_name in self._declared_names: 5127 chords_name = f"{base_name}#{suffix}" 5128 suffix += 1 5129 5130 chords_builder.__name__ = chords_name 5131 self._declared_names.add(chords_name) 5132 5133 primary: typing.Optional[typing.Tuple[int, int]] 5134 if isinstance(device, str): 5135 primary = None 5136 else: 5137 primary = (device if device is not None else 0, resolved_channel) 5138 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5139 5140 if self._is_live and chords_builder.__name__ in self._running_patterns: 5141 running = self._running_patterns[chords_builder.__name__] 5142 running._builder_fn = chords_builder 5143 running._wants_chord = False 5144 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 5145 return timeline 5146 5147 pending = _PendingPattern( 5148 builder_fn = chords_builder, 5149 channel = resolved_channel, 5150 length = beat_length, 5151 default_grid = default_grid, 5152 drum_note_map = None, 5153 reschedule_lookahead = 1, 5154 voice_leading = False, 5155 mirrors = resolved_mirrors, 5156 device = 0 if (device is None or isinstance(device, str)) else device, 5157 raw_device = device, 5158 ) 5159 self._pending_patterns.append(pending) 5160 return timeline 5161 5162 def phrase_part ( 5163 self, 5164 *, 5165 channel: int, 5166 part: typing.Optional[str] = None, 5167 root: int = 60, 5168 bars: typing.Optional[float] = None, 5169 beats: typing.Optional[float] = None, 5170 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 5171 fit: typing.Optional[float] = None, 5172 device: subsequence.midi_utils.DeviceId = None, 5173 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5174 ) -> None: 5175 5176 """Declare a part that plays each section's bound Motif/Phrase. 5177 5178 The one-call consumer for :meth:`section_motifs` — it registers a 5179 pattern on *channel* that walks whatever value is bound to the 5180 current section for *part* (stateless position from the cycle 5181 counter, via ``p.phrase()``). A section with no binding for the 5182 part is **silent** for that part — bind material or don't; no 5183 fallback guessing. 5184 5185 Parameters: 5186 channel: MIDI channel for the part. 5187 part: The part label to read from the registry (``None`` = the 5188 unlabelled binding). 5189 root: Register anchor for degree resolution. 5190 bars / beats: Cycle length of the part (defaults to 4 beats); 5191 the phrase is sliced one cycle window at a time. 5192 velocity: Optional override applied to every note. 5193 fit: Passed through (active with the melody engine stage). 5194 device: Optional output-device override. 5195 mirrors: Optional additional ``(device, channel)`` destinations. 5196 5197 Example:: 5198 5199 composition.section_motifs("verse", verse_line, part="lead") 5200 composition.section_motifs("chorus", chorus_line, part="lead") 5201 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 5202 """ 5203 5204 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5205 resolved_channel = self._resolve_channel(channel) 5206 5207 captured_part = part 5208 captured_root = root 5209 captured_velocity = velocity 5210 captured_fit = fit 5211 5212 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5213 5214 """Walk the current section's bound value (silent when unbound).""" 5215 5216 value = p.section_motif(captured_part) 5217 5218 if value is None: 5219 return # unbound section: silence for this part, by design 5220 5221 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 5222 5223 # Unique, stable name so multiple phrase parts don't collide — 5224 # including two parts on the SAME channel (deterministic #2/#3 5225 # suffixes in declaration order, the chords() convention). 5226 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 5227 phrase_name = base_name 5228 suffix = 2 5229 5230 while phrase_name in self._declared_names: 5231 phrase_name = f"{base_name}#{suffix}" 5232 suffix += 1 5233 5234 phrase_builder.__name__ = phrase_name 5235 self._declared_names.add(phrase_name) 5236 5237 primary: typing.Optional[typing.Tuple[int, int]] 5238 if isinstance(device, str): 5239 primary = None 5240 else: 5241 primary = (device if device is not None else 0, resolved_channel) 5242 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5243 5244 if self._is_live and phrase_builder.__name__ in self._running_patterns: 5245 running = self._running_patterns[phrase_builder.__name__] 5246 running._builder_fn = phrase_builder 5247 running._wants_chord = False 5248 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 5249 return 5250 5251 pending = _PendingPattern( 5252 builder_fn = phrase_builder, 5253 channel = resolved_channel, 5254 length = beat_length, 5255 default_grid = default_grid, 5256 drum_note_map = None, 5257 reschedule_lookahead = 1, 5258 voice_leading = False, 5259 mirrors = resolved_mirrors, 5260 device = 0 if (device is None or isinstance(device, str)) else device, 5261 raw_device = device, 5262 ) 5263 self._pending_patterns.append(pending) 5264 5265 def trigger ( 5266 self, 5267 fn: typing.Callable, 5268 channel: int, 5269 beats: typing.Optional[float] = None, 5270 bars: typing.Optional[float] = None, 5271 steps: typing.Optional[float] = None, 5272 step_duration: typing.Optional[float] = None, 5273 quantize: float = 0, 5274 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 5275 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 5276 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 5277 chord: bool = False, 5278 device: subsequence.midi_utils.DeviceId = None, 5279 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5280 ) -> None: 5281 5282 """ 5283 Trigger a one-shot pattern immediately or on a quantized boundary. 5284 5285 This is useful for real-time response to sensors, OSC messages, or other 5286 external events. The builder function is called immediately with a fresh 5287 PatternBuilder, and the generated events are injected into the queue at 5288 the specified quantize boundary. 5289 5290 The builder function has the same API as a ``@composition.pattern`` 5291 decorated function and can use all PatternBuilder methods: ``p.note()``, 5292 ``p.euclidean()``, ``p.arpeggio()``, and so on. 5293 5294 See ``pattern()`` for the full description of ``beats``, ``bars``, 5295 ``steps``, and ``step_duration``. Default is 1 beat. 5296 5297 Parameters: 5298 fn: The pattern builder function (same signature as ``@comp.pattern``). 5299 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 5300 beats: Duration in beats (quarter notes, default 1). 5301 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 5302 steps: Step count for step mode. Requires ``step_duration=``. 5303 step_duration: Duration of one step in beats. Requires ``steps=``. 5304 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 5305 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 5306 constants from ``subsequence.constants.durations``. 5307 drum_note_map: Optional drum name mapping for this pattern. 5308 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 5309 nrpn_name_map: Optional mapping of NRPN parameter names to 5310 14-bit parameter numbers. 5311 chord: If ``True``, the builder function receives the current chord as 5312 a second parameter (same as ``@composition.pattern``). 5313 mirrors: Optional list of additional ``(device, channel)`` destinations 5314 to fire this one-shot onto in parallel with the primary destination. 5315 5316 Example: 5317 ```python 5318 # Immediate single note (channels are 1-16 by default) 5319 composition.trigger( 5320 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 5321 channel=1 5322 ) 5323 5324 # Quantized fill (next bar) — channel 10 is the GM drum channel 5325 import subsequence.constants.durations as dur 5326 composition.trigger( 5327 lambda p: p.euclidean("snare", pulses=7, velocity=90), 5328 channel=10, 5329 drum_note_map=gm_drums.GM_DRUM_MAP, 5330 quantize=dur.WHOLE 5331 ) 5332 5333 # With chord context — the builder receives the chord as a second 5334 # argument when chord=True. 5335 composition.trigger( 5336 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 5337 channel=1, 5338 quantize=dur.QUARTER, 5339 chord=True 5340 ) 5341 ``` 5342 """ 5343 5344 # Resolve channel numbering 5345 resolved_channel = self._resolve_channel(channel) 5346 5347 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 5348 5349 # Resolve device index — for trigger() this is always concrete by call time, 5350 # so the mirror-to-self check has the full primary tuple available. 5351 resolved_device_idx = self._resolve_device_id(device) 5352 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 5353 5354 # Create a temporary Pattern 5355 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 5356 5357 # Resolve the section context once: the one-shot inherits the section's 5358 # effective key/scale (so a triggered degree resolves like everywhere 5359 # else) and a harmony view at the current playhead (so ChordTone / 5360 # Approach resolve too). 5361 trigger_section = self._form_state.get_section_info() if self._form_state else None 5362 trigger_key, trigger_scale = self._effective_key_scale(trigger_section) 5363 5364 trigger_harmony: typing.Optional[HarmonyView] = None 5365 if not self._harmony_horizon.is_empty: 5366 trigger_harmony = HarmonyView(self._harmony_horizon, self._sequencer.pulse_count / self._sequencer.pulses_per_beat) 5367 5368 # Create a PatternBuilder 5369 builder = subsequence.pattern_builder.PatternBuilder( 5370 pattern=pattern, 5371 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 5372 drum_note_map=drum_note_map, 5373 cc_name_map=cc_name_map, 5374 nrpn_name_map=nrpn_name_map, 5375 section=trigger_section, 5376 bar=self._builder_bar, 5377 conductor=self.conductor, 5378 rng=random.Random(), # Fresh random state for each trigger 5379 tweaks={}, 5380 default_grid=default_grid, 5381 data=self.data, 5382 # A one-shot resolves key-relative content against the same 5383 # effective key/scale as the section it fires into (previously 5384 # omitted entirely — degrees raised even in a keyed composition). 5385 key=trigger_key, 5386 scale=trigger_scale, 5387 time_signature=self.time_signature, 5388 held_notes=self._sequencer._held_notes, 5389 harmony=trigger_harmony, 5390 energy=self._current_energy(trigger_section) 5391 ) 5392 5393 # Call the builder function 5394 try: 5395 5396 current_chord = self.current_chord() if chord else None 5397 5398 if current_chord is not None: 5399 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 5400 fn(builder, injected) 5401 5402 else: 5403 fn(builder) 5404 5405 except Exception: 5406 logger.exception("Error in trigger builder — pattern will be silent") 5407 return 5408 5409 # Calculate the start pulse based on quantize 5410 current_pulse = self._sequencer.pulse_count 5411 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 5412 5413 if quantize == 0: 5414 # Immediate: use current pulse 5415 start_pulse = current_pulse 5416 5417 else: 5418 # Quantize to the next multiple of (quantize * pulses_per_beat) 5419 quantize_pulses = int(quantize * pulses_per_beat) 5420 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 5421 5422 self._schedule_one_shot(pattern, start_pulse) 5423 5424 def _schedule_one_shot (self, pattern: subsequence.pattern.Pattern, start_pulse: int) -> None: 5425 5426 """Schedule a one-shot pattern at an absolute pulse, thread-safely.""" 5427 5428 try: 5429 # Probe only: raises RuntimeError when not on the event loop. 5430 asyncio.get_running_loop() 5431 asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) 5432 5433 except RuntimeError: 5434 # Not on the event loop — hand the coroutine to the loop thread. 5435 if self._sequencer._event_loop is not None: 5436 asyncio.run_coroutine_threadsafe( 5437 self._sequencer.schedule_pattern(pattern, start_pulse), 5438 loop=self._sequencer._event_loop 5439 ) 5440 else: 5441 logger.warning("trigger() called before playback started; pattern ignored") 5442 5443 @property 5444 def is_clock_following (self) -> bool: 5445 5446 """True if either the primary or any additional device is following external clock.""" 5447 5448 return self._clock_follow or any(cf for _, _, cf in self._additional_inputs) 5449 5450 5451 def play (self) -> None: 5452 5453 """ 5454 Start the composition. 5455 5456 This call blocks until the program is interrupted (e.g., via Ctrl+C). 5457 It initializes the MIDI hardware, launches the background sequencer, 5458 and begins playback. 5459 """ 5460 5461 try: 5462 asyncio.run(self._run()) 5463 5464 except KeyboardInterrupt: 5465 pass 5466 5467 5468 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 5469 5470 """Render the composition to a MIDI file without real-time playback. 5471 5472 Runs the sequencer as fast as possible (no timing delays) and stops 5473 when the first active limit is reached. The result is saved as a 5474 standard MIDI file that can be imported into any DAW. 5475 5476 All patterns, scheduled callbacks, and harmony logic run exactly as 5477 they would during live playback — BPM transitions, generative fills, 5478 and probabilistic gates all work in render mode. The only difference 5479 is that time is simulated rather than wall-clock driven. 5480 5481 Parameters: 5482 bars: Number of bars to render, or ``None`` for no bar limit 5483 (default ``None``). When both *bars* and *max_minutes* are 5484 active, playback stops at whichever limit is reached first. 5485 filename: Output MIDI filename (default ``"render.mid"``). 5486 max_minutes: Safety cap on the length of rendered MIDI in minutes 5487 (default ``60.0``). Pass ``None`` to disable the time 5488 cap — you must then provide an explicit *bars* value. 5489 5490 Raises: 5491 ValueError: If both *bars* and *max_minutes* are ``None``, which 5492 would produce an infinite render. 5493 5494 Examples: 5495 ```python 5496 # Default: renders up to 60 minutes of MIDI content. 5497 composition.render() 5498 5499 # Render exactly 64 bars (time cap still active as backstop). 5500 composition.render(bars=64, filename="demo.mid") 5501 5502 # Render up to 5 minutes of an infinite generative composition. 5503 composition.render(max_minutes=5, filename="five_min.mid") 5504 5505 # Remove the time cap — must supply bars instead. 5506 composition.render(bars=128, max_minutes=None, filename="long.mid") 5507 ``` 5508 """ 5509 5510 if bars is None and max_minutes is None: 5511 raise ValueError( 5512 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 5513 "Passing both as None would produce an infinite render." 5514 ) 5515 5516 self._sequencer.recording = True 5517 self._sequencer.record_filename = filename 5518 self._sequencer.render_mode = True 5519 self._sequencer.render_bars = bars if bars is not None else 0 5520 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 5521 asyncio.run(self._run()) 5522 5523 def _broadcast_osc_status (self, bar: int) -> None: 5524 5525 """ 5526 Send the per-bar OSC status snapshot: bar number, current tempo, 5527 and (when active) the current chord name and form section. 5528 """ 5529 5530 if self._osc_server: 5531 self._osc_server.send("/bar", bar) 5532 self._osc_server.send("/bpm", self._sequencer.current_bpm) 5533 5534 sounding = self.current_chord() 5535 if sounding is not None: 5536 self._osc_server.send("/chord", sounding.name()) 5537 5538 if self._form_state: 5539 info = self._form_state.get_section_info() 5540 if info: 5541 self._osc_server.send("/section", info.name) 5542 5543 async def _run (self) -> None: 5544 5545 """ 5546 Async entry point that schedules all patterns and runs the sequencer. 5547 """ 5548 5549 # 1. Pre-calculate MIDI input indices and configure sequencer clock follow. 5550 if self._input_device is not None: 5551 self._sequencer.input_device_name = self._input_device 5552 self._sequencer.clock_follow = self._clock_follow 5553 self._sequencer.clock_device_idx = 0 5554 5555 if not self._clock_follow: 5556 # Find first additional input that wants to be the clock master. 5557 for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1): 5558 if cf: 5559 self._sequencer.clock_follow = True 5560 self._sequencer.clock_device_idx = idx 5561 break 5562 5563 # Populate input device name mapping early (before opening ports) so we can 5564 # resolve CC mappings to integer device indices immediately. 5565 if self._sequencer.input_device_name: 5566 self._input_device_names[self._sequencer.input_device_name] = 0 5567 if self._input_device_alias is not None: 5568 self._input_device_names[self._input_device_alias] = 0 5569 5570 for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1): 5571 self._input_device_names[dev_name] = idx 5572 if alias: 5573 self._input_device_names[alias] = idx 5574 5575 # 2. Pre-calculate output device names. 5576 if self._sequencer.output_device_name: 5577 self._output_device_names[self._sequencer.output_device_name] = 0 5578 # Primary device (index 0) is open by now (_init_midi_output ran in 5579 # the Sequencer constructor), so its latency can be set safely here. 5580 if self._output_latency_ms: 5581 self._sequencer.set_device_latency(0, self._output_latency_ms) 5582 5583 # 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the 5584 # input-names map is fully populated above, and the callback thread needs 5585 # integer indices as soon as ports open. OUTPUT names (cc_forward 5586 # output_device=, pattern device=) resolve after the additional outputs 5587 # are opened below; resolving them here matched against a map containing 5588 # only the primary and silently routed everything to device 0. 5589 for mapping in self._cc_mappings: 5590 raw = mapping.get('input_device') 5591 if isinstance(raw, str): 5592 mapping['input_device'] = self._resolve_input_device_id(raw) 5593 for fwd in self._cc_forwards: 5594 raw_in = fwd.get('input_device') 5595 if isinstance(raw_in, str): 5596 fwd['input_device'] = self._resolve_input_device_id(raw_in) 5597 5598 # 4. Share CC input mappings, forwards, and a reference to composition.data 5599 # with the sequencer BEFORE opening the ports. This ensures that any initial 5600 # messages in the OS buffer are correctly mapped as soon as the port opens. 5601 self._sequencer.cc_mappings = self._cc_mappings 5602 self._sequencer.cc_forwards = self._cc_forwards 5603 self._sequencer._composition_data = self.data 5604 5605 # Held-note input: create the tracker and resolve its channel/device 5606 # filter so the callback thread can buffer matching note events. 5607 if self._note_input is not None: 5608 if self._input_device is None and not self._additional_inputs: 5609 raise RuntimeError("note_input() requires a MIDI input — call composition.midi_input(device) first") 5610 raw_dev = self._note_input.get('input_device') 5611 if isinstance(raw_dev, str): 5612 raw_dev = self._resolve_input_device_id(raw_dev) 5613 self._sequencer._note_input_channel = self._note_input['channel'] 5614 self._sequencer._note_input_device = raw_dev 5615 self._sequencer._held_notes = subsequence.held_notes.HeldNotes( 5616 release_ms = self._note_input['release_ms'], 5617 latch = self._note_input['latch'], 5618 ) 5619 5620 # 5. Open MIDI input ports early. Even without a deliberate sleep, opening 5621 # them before pattern building minimizes the window for missed messages. 5622 # Primary input 5623 self._sequencer._open_midi_inputs() 5624 5625 # Additional inputs 5626 for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1): 5627 # Use the pre-calculated index 5628 callback = self._sequencer._make_input_callback(idx) 5629 open_name, port = subsequence.midi_utils.select_input_device(dev_name, callback) 5630 if open_name and port is not None: 5631 self._sequencer.add_input_device(open_name, port) 5632 else: 5633 logger.warning(f"Could not open additional input device '{dev_name}'") 5634 5635 # 6. Open additional MIDI output devices. 5636 for out in self._additional_outputs: 5637 open_name, port = subsequence.midi_utils.select_output_device(out.device) 5638 if open_name and port is not None: 5639 idx = self._sequencer.add_output_device(open_name, port, out.latency_ms) 5640 self._output_device_names[open_name] = idx 5641 if out.alias is not None: 5642 self._output_device_names[out.alias] = idx 5643 else: 5644 logger.warning(f"Could not open additional output device '{out.device}'") 5645 5646 # Warn if latency compensation adds noticeable whole-rig delay: the 5647 # slowest device defines the alignment point, so every faster device is 5648 # delayed up to that amount and live-input feel suffers. 5649 self._warn_if_high_latency() 5650 5651 # Resolve any name-based output device IDs on patterns that may have been added 5652 # for additional output devices. 5653 self._resolve_pending_devices() 5654 5655 # Resolve cc_forward output-device names now that every output port and 5656 # alias is registered (resolving earlier silently routed to device 0). 5657 for fwd in self._cc_forwards: 5658 raw_out = fwd.get('output_device') 5659 if isinstance(raw_out, str): 5660 fwd['output_device'] = self._resolve_device_id(raw_out) 5661 5662 # Pass clock output flag (suppressed automatically when clock_follow=True). 5663 self._sequencer.clock_output = self._clock_output and not self.is_clock_following 5664 5665 # Create Ableton Link clock if comp.link() was called. 5666 if self._link_quantum is not None: 5667 self._sequencer._link_clock = subsequence.link_clock.LinkClock( 5668 bpm = self.bpm, 5669 quantum = self._link_quantum, 5670 loop = asyncio.get_running_loop(), 5671 ) 5672 5673 # Deal play-time streams. Every stream is NAME-keyed (crc32 of 5674 # "seed:name", see _stream_seed) rather than dealt from one master in 5675 # registration order: adding or removing one consumer can never shift 5676 # another's stream, and patterns added live derive identically in 5677 # _build_pattern_from_pending. When no seed is set, components keep 5678 # their own unseeded RNGs (existing behaviour). 5679 if self._seed is not None: 5680 5681 harmony_stream = self._stream("play:harmony") 5682 if self._harmonic_state is not None and harmony_stream is not None: 5683 self._harmonic_state.rng = harmony_stream 5684 5685 form_stream = self._stream("play:form") 5686 if self._form_state is not None and form_stream is not None: 5687 self._form_state._rng = form_stream 5688 5689 # The clocks fire BEFORE pattern rebuilds at the same pulse, and their 5690 # lookahead is RAISED to the maximum pattern lookahead (never patterns 5691 # clamped down): when a pattern rebuilds for its next cycle, the form 5692 # state and the harmony window already describe that cycle. 5693 bar_beats = float(self.time_signature[0]) 5694 5695 pattern_lookaheads = [pending.reschedule_lookahead for pending in self._pending_patterns] 5696 pattern_lookaheads += [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] 5697 max_pattern_lookahead = max(pattern_lookaheads, default = 1) 5698 5699 clock_lookahead = max(1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead)) 5700 5701 if clock_lookahead > bar_beats: 5702 logger.warning( 5703 "A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — " 5704 "the harmony/form clocks fire at most one bar ahead, so that pattern may " 5705 "rebuild before the window covers its cycle start.", 5706 clock_lookahead, bar_beats, 5707 ) 5708 clock_lookahead = bar_beats 5709 5710 # Minimum span >= maximum lookahead: the clock cannot prepare a chord 5711 # boundary that arrives sooner than it fires. Harmonic motion faster 5712 # than this floor stays available at the part level (p.progression), 5713 # where placement is not clock-bound. 5714 def _check_span_floor (progression: typing.Optional[Progression], label: str) -> None: 5715 if progression is None: 5716 return 5717 shortest = min(span.beats for span in progression.spans) 5718 if shortest < clock_lookahead - 1e-9: 5719 raise ValueError( 5720 f"{label}: shortest chord span ({shortest:g} beats) is below the clock " 5721 f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). " 5722 "Lengthen the span, lower the pattern lookaheads, or place fast harmony " 5723 "at the part level with p.progression()." 5724 ) 5725 5726 _check_span_floor(self._bound_progression, "harmony(progression=)") 5727 for section_name, section_progression in self._section_progressions.items(): 5728 _check_span_floor(section_progression, f"section_chords({section_name!r})") 5729 5730 # Key-relative section progressions resolve late, per occurrence — so 5731 # verify they WILL resolve now, before playback, rather than surfacing 5732 # a silent skip (or a dead clock) mid-render. For each occurrence's 5733 # effective key+scale: a missing key, or a degree/scale that does not 5734 # resolve, is raised here with an actionable message. 5735 fs = self._form_state 5736 5737 for section_name, section_progression in self._section_progressions.items(): 5738 if section_progression.is_concrete: 5739 continue 5740 5741 # The (key, scale) contexts this section may be resolved against. 5742 contexts: typing.List[typing.Tuple[typing.Optional[str], typing.Optional[str]]] = [] 5743 if fs is not None and fs._sequence is not None and any(s.name == section_name for s in fs._sequence): 5744 for section in fs._sequence: 5745 if section.name != section_name: 5746 continue 5747 ctx = (section.key or self._form_key or self.key, section.scale or self._form_scale or self.scale) 5748 if ctx not in contexts: 5749 contexts.append(ctx) 5750 else: 5751 contexts.append((self._form_key or self.key, self._form_scale or self.scale)) 5752 5753 for ctx_key, ctx_scale in contexts: 5754 if ctx_key is None: 5755 raise ValueError( 5756 f"section_chords({section_name!r}) is key-relative (degrees/romans) but no key " 5757 "resolves for it — set key= on the Composition, a form key (form(key=...)), or " 5758 f"a Section.key on every {section_name!r} section." 5759 ) 5760 try: 5761 section_progression.resolve(ctx_key, ctx_scale or "ionian") 5762 except ValueError as error: 5763 raise ValueError( 5764 f"section_chords({section_name!r}) does not resolve against its effective key " 5765 f"{ctx_key} {ctx_scale or 'ionian'}: {error}" 5766 ) 5767 5768 # min_energy with nothing feeding p.energy is a silent no-op — warn loudly. 5769 energy_gated = [p.builder_fn.__name__ for p in self._pending_patterns if p.min_energy is not None] 5770 5771 if energy_gated and not self._energy_map and not self._form_has_payload: 5772 logger.warning( 5773 f"min_energy is set on {', '.join(energy_gated)} but no energy source is " 5774 "configured — p.energy is always 0.5 (call composition.energy() or bind a " 5775 "Form whose Sections carry energy)" 5776 ) 5777 5778 # The form clock MUST be registered before the harmonic clock: same-pulse 5779 # fixed callbacks fire in registration order (and all fixed callbacks fire 5780 # before callback sequences), and on a section-boundary bar the harmonic 5781 # clock reads the current section (via _get_section_progression) to decide 5782 # whether to walk that section's chords. Registering harmony first would 5783 # make it read the OLD section on every boundary, shifting section_chords() 5784 # replays by one bar and bleeding them across sections. 5785 if self._form_state is not None: 5786 5787 await schedule_form( 5788 sequencer = self._sequencer, 5789 form_state = self._form_state, 5790 reschedule_lookahead = clock_lookahead, 5791 on_bar = self._check_transitions, 5792 # Re-read every bar so a mid-playback form() re-bind advances 5793 # the NEW state instead of the abandoned object. 5794 get_form_state = lambda: self._form_state, 5795 ) 5796 5797 self._harmony_horizon.reset() 5798 self._harmonic_clock_started = False 5799 5800 if self._harmonic_state is not None or self._bound_progression is not None or self._section_progressions: 5801 await self._start_harmonic_clock(bar_beats, clock_lookahead) 5802 5803 # Bar counter - always active so p.bar is available to all builders. 5804 def _advance_builder_bar (pulse: int) -> None: 5805 self._builder_bar += 1 5806 5807 first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat) 5808 5809 await self._sequencer.schedule_callback_repeating( 5810 callback = _advance_builder_bar, 5811 interval_beats = self.time_signature[0], 5812 start_pulse = first_bar_pulse, 5813 # Same raised lookahead as the form/harmony clocks: a pattern 5814 # rebuilding lookahead-early for its next cycle must read the bar 5815 # that cycle starts in, not the previous one. 5816 reschedule_lookahead = clock_lookahead 5817 ) 5818 5819 # Run wait_for_initial=True scheduled functions and block until all complete. 5820 # This ensures composition.data is populated before patterns build. 5821 initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial] 5822 5823 if initial_tasks: 5824 5825 names = ", ".join(getattr(t.fn, '__name__', repr(t.fn)) for t in initial_tasks) 5826 logger.info(f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}") 5827 5828 async def _run_initial (fn: typing.Callable) -> None: 5829 5830 accepts_ctx = _fn_has_parameter(fn, "p") 5831 ctx = ScheduleContext(cycle=0) 5832 5833 try: 5834 if inspect.iscoroutinefunction(fn): 5835 await (fn(ctx) if accepts_ctx else fn()) 5836 else: 5837 loop = asyncio.get_running_loop() 5838 call = (lambda: fn(ctx)) if accepts_ctx else fn 5839 await loop.run_in_executor(None, call) 5840 except Exception as exc: 5841 logger.warning(f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}") 5842 5843 await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks]) 5844 5845 for pending_task in self._pending_scheduled: 5846 5847 accepts_ctx = _fn_has_parameter(pending_task.fn, "p") 5848 5849 # A wait_for_initial task already ran once as cycle 0 (the blocking 5850 # pre-roll above), so its repeating wrapper starts at cycle 1 — keeping 5851 # ScheduleContext.cycle monotonic across the initial and repeating runs. 5852 wrapped = _make_safe_callback( 5853 pending_task.fn, 5854 accepts_context = accepts_ctx, 5855 start_cycle = 1 if pending_task.wait_for_initial else 0, 5856 ) 5857 5858 # wait_for_initial=True implies defer — no point firing at pulse 0 5859 # after the blocking run just completed. defer=True skips the 5860 # backshift fire so the first repeating call happens one full cycle 5861 # later. 5862 if pending_task.wait_for_initial or pending_task.defer: 5863 start_pulse = int(pending_task.cycle_beats * self._sequencer.pulses_per_beat) 5864 else: 5865 start_pulse = 0 5866 5867 await self._sequencer.schedule_callback_repeating( 5868 callback = wrapped, 5869 interval_beats = pending_task.cycle_beats, 5870 start_pulse = start_pulse, 5871 reschedule_lookahead = pending_task.reschedule_lookahead 5872 ) 5873 5874 # Build Pattern objects from pending registrations. 5875 patterns: typing.List[subsequence.pattern.Pattern] = [] 5876 5877 for i, pending in enumerate(self._pending_patterns): 5878 5879 pattern = self._build_pattern_from_pending(pending) 5880 patterns.append(pattern) 5881 5882 await schedule_patterns( 5883 sequencer = self._sequencer, 5884 patterns = patterns, 5885 start_pulse = 0 5886 ) 5887 5888 # Populate the running patterns dict for live hot-swap and mute/unmute. 5889 for i, pending in enumerate(self._pending_patterns): 5890 name = pending.builder_fn.__name__ 5891 self._running_patterns[name] = patterns[i] 5892 5893 # Everything pending is running now; drop the declarations so a later 5894 # live reload cannot graduate stale copies. 5895 self._pending_patterns = [] 5896 5897 if self._display is not None and not self._sequencer.render_mode: 5898 self._display.start() 5899 self._sequencer.on_event("bar", self._display.update) 5900 self._sequencer.on_event("beat", self._display.update) 5901 5902 if self._live_server is not None: 5903 await self._live_server.start() 5904 5905 if self._osc_server is not None: 5906 await self._osc_server.start() 5907 self._sequencer.osc_server = self._osc_server 5908 self._sequencer.on_event("bar", self._broadcast_osc_status) 5909 5910 # Start keystroke listener if hotkeys are enabled and not in render mode. 5911 if self._hotkeys_enabled and not self._sequencer.render_mode: 5912 self._keystroke_listener = subsequence.keystroke.KeystrokeListener() 5913 self._keystroke_listener.start() 5914 5915 if self._keystroke_listener.active: 5916 # Listener started successfully — register the bar handler 5917 # and show all bindings so the user knows what's available. 5918 self._sequencer.on_event("bar", self._process_hotkeys) 5919 self._list_hotkeys() 5920 # If not active, KeystrokeListener.start() already logged a warning. 5921 5922 if self._web_ui_enabled and not self._sequencer.render_mode: 5923 self._web_ui_server = subsequence.web_ui.WebUI(self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host) 5924 self._web_ui_server.start() 5925 5926 try: 5927 await run_until_stopped(self._sequencer) 5928 finally: 5929 # Tear down every service even if run_until_stopped (or an earlier 5930 # stop) raised, and guard each individually, so one failure can't 5931 # strand the rest — most importantly the keystroke listener's 5932 # terminal restore. 5933 if self._web_ui_server is not None: 5934 try: 5935 self._web_ui_server.stop() 5936 except Exception: 5937 logger.exception("Error stopping web UI") 5938 5939 if self._live_server is not None: 5940 try: 5941 await self._live_server.stop() 5942 except Exception: 5943 logger.exception("Error stopping live server") 5944 5945 if self._live_reloader is not None: 5946 try: 5947 self._live_reloader.stop() 5948 except Exception: 5949 logger.exception("Error stopping live reloader") 5950 5951 if self._osc_server is not None: 5952 try: 5953 await self._osc_server.stop() 5954 except Exception: 5955 logger.exception("Error stopping OSC server") 5956 self._sequencer.osc_server = None 5957 5958 if self._display is not None: 5959 try: 5960 self._display.stop() 5961 except Exception: 5962 logger.exception("Error stopping display") 5963 5964 if self._keystroke_listener is not None: 5965 try: 5966 self._keystroke_listener.stop() 5967 except Exception: 5968 logger.exception("Error stopping keystroke listener") 5969 self._keystroke_listener = None 5970 5971 def _build_pattern_from_pending (self, pending: _PendingPattern, start_pulse: int = 0) -> subsequence.pattern.Pattern: 5972 5973 """ 5974 Create a Pattern from a pending registration using a temporary subclass. 5975 5976 The pattern's play stream is dealt here, keyed by NAME (crc32 of 5977 "seed:name" plus any reroll nonce), so registration order is 5978 irrelevant and a pattern added live gets exactly the stream it would 5979 have had at startup. ``start_pulse`` anchors the first cycle on the 5980 beat axis so the initial build reads the harmony window at the right 5981 place (the sequencer keeps the anchor current on every reschedule). 5982 """ 5983 5984 composition_ref = self 5985 rng = self._stream(pending.builder_fn.__name__) 5986 5987 class _DecoratorPattern (subsequence.pattern.Pattern): 5988 5989 """ 5990 Pattern subclass that delegates to a builder function on each reschedule. 5991 """ 5992 5993 def __init__ (self, pending: _PendingPattern, pattern_rng: typing.Optional[random.Random] = None) -> None: 5994 5995 """ 5996 Initialize the decorator pattern from pending registration details. 5997 """ 5998 5999 super().__init__( 6000 channel = pending.channel, 6001 length = pending.length, 6002 reschedule_lookahead = pending.reschedule_lookahead, 6003 device = pending.device, 6004 mirrors = pending.mirrors, 6005 ) 6006 6007 self._builder_fn = pending.builder_fn 6008 self._drum_note_map = pending.drum_note_map 6009 self._cc_name_map = pending.cc_name_map 6010 self._nrpn_name_map = pending.nrpn_name_map 6011 self._default_grid: int = pending.default_grid 6012 self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord") 6013 self._cycle_count = 0 6014 self._rng = pattern_rng 6015 self._muted = False 6016 self._min_energy = pending.min_energy 6017 self._energy_gated = False 6018 self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = ( 6019 subsequence.voicings.VoiceLeadingState() if pending.voice_leading else None 6020 ) 6021 self._tweaks: typing.Dict[str, typing.Any] = {} 6022 6023 # Anchor of the cycle being built, on the absolute pulse axis. 6024 # The sequencer updates this on every reschedule; the initial 6025 # value is the pattern's first scheduled start. 6026 self._cycle_start_pulse = start_pulse 6027 6028 self._rebuild() 6029 6030 def _rebuild (self) -> None: 6031 6032 """ 6033 Clear steps and call the builder function to repopulate. 6034 """ 6035 6036 self.steps = {} 6037 self.cc_events = [] 6038 self.osc_events = [] 6039 self.raw_note_events = [] 6040 current_cycle = self._cycle_count 6041 self._cycle_count += 1 6042 6043 # lock(): re-deal the stream from its effective seed every 6044 # rebuild so a locked pattern realizes identically each cycle. 6045 # Checked here (engine-side) so it survives live reload. 6046 if self._builder_fn.__name__ in composition_ref._locked_names: 6047 locked_seed = composition_ref._stream_seed(self._builder_fn.__name__) 6048 if locked_seed is not None: 6049 self._rng = random.Random(locked_seed) 6050 6051 if self._muted: 6052 return 6053 6054 section_info = composition_ref._form_state.get_section_info() if composition_ref._form_state else None 6055 energy = composition_ref._current_energy(section_info) 6056 effective_key, effective_scale = composition_ref._effective_key_scale(section_info) 6057 6058 # Automatic energy gating: below the threshold the pattern is 6059 # silent this cycle (composing with _muted — a performer mute 6060 # always wins). Gate flips log once. 6061 if self._min_energy is not None: 6062 gated = energy < self._min_energy 6063 6064 if gated != self._energy_gated: 6065 state_word = "closed" if gated else "open" 6066 logger.info( 6067 f"Pattern '{self._builder_fn.__name__}': energy gate {state_word} " 6068 f"(energy {energy:.2f}, min_energy {self._min_energy:g})" 6069 ) 6070 self._energy_gated = gated 6071 6072 if gated: 6073 return 6074 6075 # The harmony view for this cycle, anchored at its start beat — 6076 # under variable harmonic rhythm the window, not the engine's 6077 # mutating singleton, is the source of truth. 6078 harmony_view: typing.Optional[HarmonyView] = None 6079 6080 if not composition_ref._harmony_horizon.is_empty: 6081 origin_beat = self._cycle_start_pulse / composition_ref._sequencer.pulses_per_beat 6082 harmony_view = HarmonyView(composition_ref._harmony_horizon, origin_beat) 6083 6084 builder = subsequence.pattern_builder.PatternBuilder( 6085 pattern = self, 6086 cycle = current_cycle, 6087 drum_note_map = self._drum_note_map, 6088 cc_name_map = self._cc_name_map, 6089 nrpn_name_map = self._nrpn_name_map, 6090 section = section_info, 6091 bar = composition_ref._builder_bar, 6092 conductor = composition_ref.conductor, 6093 rng = self._rng, 6094 tweaks = self._tweaks, 6095 default_grid = self._default_grid, 6096 data = composition_ref.data, 6097 # The effective key/scale re-anchors key-relative content 6098 # (degrees, romans, generated material) to the section / 6099 # form / composition tier in force — mode travels too. 6100 key = effective_key, 6101 scale = effective_scale, 6102 time_signature = composition_ref.time_signature, 6103 held_notes = composition_ref._sequencer._held_notes, 6104 harmony = harmony_view, 6105 section_motifs = composition_ref._section_motifs, 6106 energy = energy, 6107 # So p.scratch() can take a child stream keyed off this 6108 # pattern's, rather than drawing from the pattern's own. 6109 stream_seed = composition_ref._stream_seed(self._builder_fn.__name__), 6110 ) 6111 6112 try: 6113 6114 if self._wants_chord: 6115 6116 # The two-parameter convention: the injected chord is 6117 # the cycle-start snapshot from the window (falling 6118 # back to the engine before the clock has run). 6119 chord = harmony_view.chord if harmony_view is not None else ( 6120 composition_ref._harmonic_state.get_current_chord() 6121 if composition_ref._harmonic_state is not None else None 6122 ) 6123 6124 if chord is not None: 6125 injected = _InjectedChord( 6126 chord, 6127 self._voice_leading_state, 6128 next_chord = harmony_view.next_chord if harmony_view is not None else None, 6129 beats_remaining = harmony_view.until_change if harmony_view is not None else None, 6130 ) 6131 self._builder_fn(builder, injected) 6132 else: 6133 self._builder_fn(builder) 6134 6135 else: 6136 self._builder_fn(builder) 6137 6138 except Exception: 6139 # Discard whatever the builder placed before it raised — 6140 # otherwise a half-built pattern plays and the log lies. 6141 self.steps = {} 6142 self.cc_events = [] 6143 self.osc_events = [] 6144 self.raw_note_events = [] 6145 logger.exception("Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", self._builder_fn.__name__, current_cycle) 6146 6147 # Auto-apply global tuning if set and not already applied by the builder. 6148 if ( 6149 composition_ref._tuning is not None 6150 and not builder._tuning_applied 6151 and not (composition_ref._tuning_exclude_drums and self._drum_note_map) 6152 ): 6153 import subsequence.tuning as _tuning_mod 6154 _tuning_mod.apply_tuning_to_pattern( 6155 self, 6156 composition_ref._tuning, 6157 bend_range=composition_ref._tuning_bend_range, 6158 channels=composition_ref._tuning_channels, 6159 reference_note=composition_ref._tuning_reference_note, 6160 ) 6161 6162 def on_reschedule (self) -> None: 6163 6164 """ 6165 Rebuild the pattern from the builder function before the next cycle. 6166 """ 6167 6168 self._rebuild() 6169 6170 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 pause (self) -> None: 3919 3920 """ 3921 Hold playback where it is, keeping the composition's place. 3922 3923 The clock stops advancing, sounding notes are released, and MIDI Stop 3924 is sent to any hardware following the clock output. :meth:`resume` 3925 continues from the same pulse, beat and bar — where stopping and 3926 playing again would start the piece over. 3927 3928 Bar and cycle counters hold too, so patterns resume mid-phrase rather 3929 than jumping. A note cut short by the pause is not re-struck on 3930 resume; it returns on its pattern's next cycle. 3931 3932 Idempotent and safe to call from any thread. Ignored, with a log line, 3933 when the transport is not ours to hold — under ``clock_follow=True`` or 3934 an active Ableton Link session. 3935 """ 3936 3937 self._sequencer.pause()
Hold playback where it is, keeping the composition's place.
The clock stops advancing, sounding notes are released, and MIDI Stop
is sent to any hardware following the clock output. resume()
continues from the same pulse, beat and bar — where stopping and
playing again would start the piece over.
Bar and cycle counters hold too, so patterns resume mid-phrase rather than jumping. A note cut short by the pause is not re-struck on resume; it returns on its pattern's next cycle.
Idempotent and safe to call from any thread. Ignored, with a log line,
when the transport is not ours to hold — under clock_follow=True or
an active Ableton Link session.
3939 def resume (self) -> None: 3940 3941 """ 3942 Continue playback from where :meth:`pause` held it. 3943 3944 Sends MIDI Continue rather than Start, so downstream hardware picks up 3945 where it left off instead of resetting to the top of its own pattern. 3946 Idempotent. 3947 """ 3948 3949 self._sequencer.resume()
Continue playback from where pause() held it.
Sends MIDI Continue rather than Start, so downstream hardware picks up where it left off instead of resetting to the top of its own pattern. Idempotent.
3951 @property 3952 def is_paused (self) -> bool: 3953 3954 """True while playback is held by :meth:`pause`.""" 3955 3956 return self._sequencer.paused
True while playback is held by pause().
3958 def mute (self, name: str) -> None: 3959 3960 """ 3961 Mute a running pattern by name. 3962 3963 The pattern continues to 'run' and increment its cycle count in 3964 the background, but it will not produce any MIDI notes until unmuted. 3965 3966 Parameters: 3967 name: The function name of the pattern to mute. 3968 """ 3969 3970 if name not in self._running_patterns: 3971 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3972 3973 # The performer takes ownership: if a transition's approach window had 3974 # muted this pattern, drop it from that set so the section boundary 3975 # does not silently unmute it ("performer mutes win"). 3976 self._transition_muted.discard(name) 3977 3978 self._running_patterns[name]._muted = True 3979 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.
3981 def unmute (self, name: str) -> None: 3982 3983 """ 3984 Unmute a previously muted pattern. 3985 """ 3986 3987 if name not in self._running_patterns: 3988 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3989 3990 # Symmetric ownership claim: an explicit unmute means the transition 3991 # machinery should no longer manage this pattern at the boundary. 3992 self._transition_muted.discard(name) 3993 3994 self._running_patterns[name]._muted = False 3995 logger.info(f"Unmuted pattern: {name}")
Unmute a previously muted pattern.
3997 def unregister (self, name: str) -> None: 3998 3999 """Fully remove a running pattern from rotation. 4000 4001 Unlike ``mute()`` (which keeps the pattern alive but silent), 4002 ``unregister()`` tears the pattern down entirely. It sets 4003 ``pattern._removed = True`` so the sequencer's reschedule loop 4004 skips re-adding it on the next pulse; sends ``note_off`` for any 4005 of the pattern's currently-sounding notes on the primary 4006 destination AND on every mirror destination (so drones and 4007 sustaining notes stop immediately); and removes the entry from 4008 ``_running_patterns`` so it no longer appears in ``live_info()``, 4009 the terminal grid, or any other consumer that enumerates running 4010 patterns. 4011 4012 Already-queued events in the sequencer's event queue play out — 4013 note_offs are paired with their note_ons at queue time, so notes 4014 end at their natural duration; only drones rely on the targeted 4015 ``_stop_pattern_notes`` pass. 4016 4017 Idempotent: silently logs a ``debug`` and returns if the pattern 4018 is already absent. Useful from both the live REPL 4019 (``composition.live()``) and the file watcher 4020 (``composition.watch()``), which calls this for any pattern 4021 removed from the watched file between reloads. 4022 4023 Parameters: 4024 name: Function name of the pattern to remove. 4025 """ 4026 4027 if name not in self._running_patterns: 4028 logger.debug(f"unregister() no-op: pattern '{name}' not running") 4029 return 4030 4031 pattern = self._running_patterns[name] 4032 4033 # Mark for removal first so the reschedule loop sees the flag even if 4034 # it fires concurrently with the note-off pass below. 4035 pattern._removed = True 4036 4037 # Stop sustaining notes (including drones) on every destination this 4038 # pattern outputs to. Fire-and-forget across threads via the event 4039 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 4040 if self._sequencer._event_loop is not None: 4041 asyncio.run_coroutine_threadsafe( 4042 self._sequencer._stop_pattern_notes(pattern), 4043 loop = self._sequencer._event_loop, 4044 ) 4045 4046 def _finalise_removal () -> None: 4047 self._running_patterns.pop(name, None) 4048 4049 # Forget any pending (not-yet-graduated) declaration too, so a 4050 # later live reload cannot resurrect the pattern. 4051 self._pending_patterns = [ 4052 pending for pending in self._pending_patterns 4053 if pending.builder_fn.__name__ != name 4054 ] 4055 4056 logger.info(f"Unregistered pattern: {name}") 4057 4058 # The running-patterns dict is iterated by the display, web UI, and 4059 # reschedule loop on the event loop thread — mutate it there when this 4060 # call arrives from another thread (e.g. the live TCP server). 4061 loop = self._sequencer._event_loop 4062 4063 try: 4064 on_loop = loop is not None and asyncio.get_running_loop() is loop 4065 except RuntimeError: 4066 on_loop = False 4067 4068 if loop is not None and loop.is_running() and not on_loop: 4069 loop.call_soon_threadsafe(_finalise_removal) 4070 else: 4071 _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.
4073 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 4074 4075 """ 4076 Add a mirror destination to a running pattern. 4077 4078 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 4079 event the pattern emits will also be sent to ``(device, channel)``, 4080 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 4081 — calling with the same destination twice does not double-fan; calling 4082 again with a different ``drum_note_map`` re-points it in place. 4083 4084 Parameters: 4085 name: Function name of the pattern to mirror. 4086 device: Output device index (the integer returned from 4087 ``midi_output()``; 0 = primary device). 4088 channel: MIDI channel using this composition's numbering convention 4089 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 4090 drum_note_map: Optional per-destination drum map. When set, mirrored 4091 drum hits are re-resolved by name through it, so a named voice 4092 lands on this device's own note number — see the README 4093 "MIDI mirroring" section. 4094 4095 Bandwidth: each mirror adds another full copy of the pattern's events. 4096 See the README "MIDI mirroring" section for the tradeoffs. 4097 """ 4098 4099 if name not in self._running_patterns: 4100 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4101 4102 resolved_channel = self._resolve_channel(channel) 4103 prefix = (device, resolved_channel) 4104 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 4105 4106 pattern = self._running_patterns[name] 4107 4108 # Mirror-to-self check: comparing the (device, channel) prefix against the 4109 # live pattern's resolved destination. Unlike the decorator path this is 4110 # always concrete. 4111 if prefix == (pattern.device, pattern.channel): 4112 logger.warning( 4113 f"Mirror destination {prefix} matches '{name}'s primary destination " 4114 f"— every event will double-fire on this (device, channel). This is almost " 4115 f"certainly unintended." 4116 ) 4117 4118 # Idempotent on (device, channel): replace any existing entry for the same 4119 # destination (so its map can be re-pointed), else append. 4120 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 4121 if existing_index is None: 4122 pattern.mirrors.append(entry) 4123 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 4124 elif pattern.mirrors[existing_index] != entry: 4125 pattern.mirrors[existing_index] = entry 4126 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 4127 else: 4128 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.
4130 def unmirror (self, name: str, device: int, channel: int) -> None: 4131 4132 """ 4133 Remove a single mirror destination from a running pattern. 4134 4135 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 4136 ignored. Idempotent: silently does nothing if the destination is not 4137 currently mirrored. The change applies on the next cycle rebuild. 4138 """ 4139 4140 if name not in self._running_patterns: 4141 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4142 4143 resolved_channel = self._resolve_channel(channel) 4144 prefix = (device, resolved_channel) 4145 4146 pattern = self._running_patterns[name] 4147 4148 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 4149 if len(filtered) != len(pattern.mirrors): 4150 pattern.mirrors[:] = filtered 4151 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 4152 else: 4153 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.
4155 def unmirror_all (self, name: str) -> None: 4156 4157 """ 4158 Remove every mirror destination from a running pattern. 4159 """ 4160 4161 if name not in self._running_patterns: 4162 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4163 4164 pattern = self._running_patterns[name] 4165 4166 if pattern.mirrors: 4167 pattern.mirrors.clear() 4168 logger.info(f"All mirrors cleared on pattern: {name}")
Remove every mirror destination from a running pattern.
4170 def tweak (self, name: str, **kwargs: typing.Any) -> None: 4171 4172 """Override parameters for a running pattern. 4173 4174 Values set here are available inside the pattern's builder 4175 function via ``p.param()``. They persist across rebuilds 4176 until explicitly changed or cleared. Changes take effect 4177 on the next rebuild cycle. 4178 4179 Parameters: 4180 name: The function name of the pattern. 4181 ``**kwargs``: Parameter names and their new values. 4182 4183 Example (from the live REPL):: 4184 4185 composition.tweak("bass", pitches=[48, 52, 55, 60]) 4186 """ 4187 4188 if name not in self._running_patterns: 4189 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4190 4191 self._running_patterns[name]._tweaks.update(kwargs) 4192 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])
4194 def clear_tweak (self, name: str, *param_names: str) -> None: 4195 4196 """Remove tweaked parameters from a running pattern. 4197 4198 If no parameter names are given, all tweaks for the pattern 4199 are cleared and every ``p.param()`` call reverts to its 4200 default. 4201 4202 Parameters: 4203 name: The function name of the pattern. 4204 *param_names: Specific parameter names to clear. If 4205 omitted, all tweaks are removed. 4206 """ 4207 4208 if name not in self._running_patterns: 4209 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4210 4211 if not param_names: 4212 self._running_patterns[name]._tweaks.clear() 4213 logger.info(f"Cleared all tweaks for pattern '{name}'") 4214 else: 4215 for param_name in param_names: 4216 self._running_patterns[name]._tweaks.pop(param_name, None) 4217 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.
4219 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 4220 4221 """Return a copy of the current tweaks for a running pattern. 4222 4223 Parameters: 4224 name: The function name of the pattern. 4225 """ 4226 4227 if name not in self._running_patterns: 4228 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4229 4230 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.
4232 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 4233 4234 """ 4235 Register a custom function to run on a repeating beat-based cycle. 4236 4237 Subsequence automatically runs synchronous functions in a thread pool 4238 so they don't block the timing-critical MIDI clock. Async functions 4239 are run directly on the event loop. 4240 4241 Parameters: 4242 fn: The function to call. 4243 cycle_beats: How often to call it (e.g., 4 = every bar). 4244 reschedule_lookahead: How far in advance to schedule the next call. 4245 wait_for_initial: If True, run the function once during startup 4246 and wait for it to complete before playback begins. This 4247 ensures ``composition.data`` is populated before patterns 4248 first build. Implies ``defer=True`` for the repeating 4249 schedule. 4250 defer: If True, skip the pulse-0 fire and defer the first 4251 repeating call to just before the second cycle boundary. 4252 4253 Raises: 4254 RuntimeError: If called after ``play()`` has started — scheduled 4255 tasks register at startup, so a late registration would be 4256 silently ignored otherwise. 4257 """ 4258 4259 if self._sequencer.running: 4260 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 4261 4262 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.
4264 def form ( 4265 self, 4266 sections: typing.Union[ 4267 "subsequence.forms.Form", 4268 typing.List[typing.Any], 4269 typing.Iterator[typing.Tuple[str, int]], 4270 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 4271 ], 4272 loop: bool = False, 4273 start: typing.Optional[str] = None, 4274 at_end: str = "stop", 4275 key: typing.Optional[str] = None, 4276 scale: typing.Optional[str] = None, 4277 ) -> None: 4278 4279 """ 4280 Define the structure (sections) of the composition. 4281 4282 You can define form in four ways: 4283 4284 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of 4285 :class:`~subsequence.forms.Section` values — the payload home 4286 (energy, key per section); editable, navigable. 4287 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples 4288 or Sections (lists coerce — they are the same form). 4289 3. **Graph (Dict)**: dynamic transitions based on weights. 4290 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. 4291 4292 Form-value and list forms are **navigable**: ``form_jump()`` and 4293 ``form_next()`` work on them (the jump lands on the next occurrence 4294 of the name, wrapping). 4295 4296 Re-binding ``form()`` during playback takes effect at the next bar — 4297 the clock reads the current form state on every bar, so the new form 4298 advances from there (its first section plays from its first bar). 4299 4300 Parameters: 4301 sections: The form definition (Form, List, Dict, or Generator). 4302 loop: Sugar for ``at_end="loop"``. 4303 start: The section to start with (Graph mode only). 4304 at_end: What happens when a sequence form runs out — 4305 ``"stop"`` (the form finishes and patterns see no section; 4306 default), ``"hold"`` (the final section repeats until 4307 navigated away from), or ``"loop"`` (start over). Graphs 4308 end via their terminal sections instead. 4309 key: A form-level key — the **form tier** of the key-source 4310 chain (``Section.key`` overrides it; it overrides the 4311 composition key). Re-anchors key-relative content for the 4312 whole form. When *sections* is a ``Form`` value carrying its 4313 own ``key``, that value is used unless this argument overrides. 4314 scale: A form-level scale/mode, paired with ``key``. 4315 4316 Example: 4317 ```python 4318 # A simple pop structure 4319 comp.form([ 4320 ("verse", 8), 4321 ("chorus", 8), 4322 ("verse", 8), 4323 ("chorus", 16) 4324 ]) 4325 4326 # The same structure with payloads, held open at the end 4327 S = subsequence.Section 4328 comp.form(subsequence.Form([ 4329 S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), 4330 ]), at_end="hold") 4331 ``` 4332 """ 4333 4334 # Seed FormState at form() time (per-call salt) so build-time walks — 4335 # the frozen clones form_freeze will take — are deterministic without 4336 # play(); the play-time stream is re-dealt name-keyed in _run(). 4337 self._form_count += 1 4338 4339 self._form_state = subsequence.form_state.FormState( 4340 sections, 4341 loop = loop, 4342 start = start, 4343 rng = self._stream(f"form:{self._form_count}"), 4344 at_end = at_end, 4345 ) 4346 4347 # A Form value carries energy payloads — that counts as an energy 4348 # source for the min_energy registration check in _run(). 4349 self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( 4350 isinstance(sections, list) and any(isinstance(element, subsequence.forms.Section) for element in sections) 4351 ) 4352 4353 # Form-tier key/scale: an explicit argument wins; otherwise a Form 4354 # value's own key/scale seeds the tier. Re-binding the form drops any 4355 # stale per-section resolution cache. 4356 if isinstance(sections, subsequence.forms.Form): 4357 self._form_key = key if key is not None else sections.key 4358 self._form_scale = scale if scale is not None else sections.scale 4359 else: 4360 self._form_key = key 4361 self._form_scale = scale 4362 4363 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")
4365 def form_freeze (self, sections: typing.Optional[int] = None) -> "subsequence.forms.Form": 4366 4367 """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. 4368 4369 Walks a **clone** of the live form state — the same RNG state, so the 4370 frozen path is exactly the path the live graph would have played — 4371 and returns it as a Form value: inspect it, edit it 4372 (``path.replace(3, bars=16)``), and rebind it with 4373 ``composition.form(path, at_end=...)``. The live form state is 4374 untouched (rebinding replaces it). 4375 4376 Parameters: 4377 sections: Number of sections to freeze. Without it, the walk 4378 runs until a terminal section; a graph with no terminal 4379 sections requires ``sections=`` explicitly. 4380 4381 Raises: 4382 ValueError: If no graph form is bound (a list form is already a 4383 frozen sequence), the form has already finished, or the walk 4384 cannot terminate. 4385 4386 Example:: 4387 4388 composition.form({...}, start="intro") 4389 path = composition.form_freeze() # the walk, frozen 4390 composition.form(path, at_end="stop") # rebind the editable value 4391 """ 4392 4393 fs = self._form_state 4394 4395 if fs is None or fs._graph is None or fs._section_bars is None: 4396 raise ValueError( 4397 "form_freeze() freezes a graph form's walk — call form() with a dict first " 4398 "(a list form is already a frozen sequence)" 4399 ) 4400 4401 if fs._current is None: 4402 raise ValueError("the form has already finished — nothing left to freeze") 4403 4404 if sections is not None and sections < 1: 4405 raise ValueError("sections must be at least 1") 4406 4407 if sections is None and not fs._terminal_sections: 4408 raise ValueError( 4409 "this graph has no terminal section, so the walk would never end — " 4410 "pass sections=n to bound it" 4411 ) 4412 4413 # Clone the RNG state: the frozen walk reproduces the live form's 4414 # future draws without consuming them. 4415 rng = random.Random() 4416 rng.setstate(fs._rng.getstate()) 4417 4418 walked = [fs._current] 4419 next_name = fs._next_section_name # already decided by the live state 4420 4421 while next_name is not None: 4422 if sections is not None and len(walked) >= sections: 4423 break 4424 if sections is None and len(walked) >= 10000: 4425 raise ValueError( 4426 "form_freeze() walked 10000 sections without reaching a terminal — " 4427 "the terminals look unreachable; pass sections=n to bound the walk" 4428 ) 4429 4430 walked.append(subsequence.forms.Section(name = next_name, bars = fs._section_bars[next_name])) 4431 next_name = None if next_name in fs._terminal_sections else fs._graph.choose_next(next_name, rng) 4432 4433 # Carry the form-tier key/scale onto the frozen value so a freeze → 4434 # rebind round-trip is lossless (an explicit form(key=) on rebind 4435 # still overrides). 4436 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
4438 def energy (self, energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]]) -> None: 4439 4440 """Set per-section energy — the arranging dial, as one plain dict. 4441 4442 ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is 4443 the section's level; a ``(start, end)`` tuple interpolates across the 4444 section (a build). Patterns read ``p.energy`` (0.5 when nothing is 4445 configured) and gate themselves, or declare ``min_energy=`` on 4446 ``pattern()`` for automatic muting. 4447 4448 The dict **overrides** any energy payload carried by bound 4449 :class:`~subsequence.forms.Section` values — it is the later, 4450 performance-level dial. Re-calling replaces the whole mapping 4451 (idempotent, live-reload friendly). 4452 4453 Example:: 4454 4455 composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) 4456 """ 4457 4458 validated: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 4459 4460 for name, value in energies.items(): 4461 if isinstance(value, tuple): 4462 if len(value) != 2: 4463 raise ValueError(f"energy ramp for {name!r} must be (start, end), got {value!r}") 4464 start_level, end_level = float(value[0]), float(value[1]) 4465 for level in (start_level, end_level): 4466 if not 0.0 <= level <= 1.0: 4467 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4468 validated[name] = (start_level, end_level) 4469 else: 4470 level = float(value) 4471 if not 0.0 <= level <= 1.0: 4472 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4473 validated[name] = level 4474 4475 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})
4507 def on_section (self, callback: typing.Callable[..., typing.Any]) -> None: 4508 4509 """Register a callback fired on every section change. 4510 4511 The callback receives the new :class:`~subsequence.form_state.SectionInfo` 4512 (or ``None`` when the form finishes). It fires from the form clock, 4513 one lookahead-beat **early** — in time to affect the new section's 4514 first patterns — and once at play start for the opening section. 4515 4516 Example:: 4517 4518 composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) 4519 """ 4520 4521 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'}"))
4523 def transition ( 4524 self, 4525 before: str, 4526 fill: typing.Optional[typing.Any] = None, 4527 channel: typing.Optional[int] = None, 4528 beat: float = 0.0, 4529 mute: typing.Optional[typing.List[str]] = None, 4530 beats: typing.Optional[float] = None, 4531 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4532 device: subsequence.midi_utils.DeviceId = None, 4533 ) -> None: 4534 4535 """Declare boundary material — the automatic fill or mute, one line. 4536 4537 ``before`` names the incoming section (``"chorus"``), or ``"*"`` for 4538 any *different* section (repeats don't fire it). Two actions, 4539 combinable: 4540 4541 - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last 4542 bar before the boundary, starting at ``beat`` of that bar. Drum 4543 names resolve through ``drum_note_map=`` if given, otherwise the 4544 map is borrowed from a registered pattern on the same channel. 4545 - ``mute=`` (+ ``beats=``): pattern names muted over the approach 4546 and unmuted at the boundary. Muting is **bar-granular** (the 4547 existing rule), so ``beats`` rounds up to whole bars. Performer 4548 mutes win: a pattern you muted yourself stays muted. 4549 4550 Transitions stack — call once per rule. Registration is additive 4551 and idempotent per identical rule. 4552 4553 Example:: 4554 4555 composition.transition(before="*", fill=FILL, channel=10, beat=2.0) 4556 composition.transition(before="drop", mute=["pads"], beats=4) 4557 """ 4558 4559 if fill is None and mute is None: 4560 raise ValueError("transition() needs fill= and/or mute= — it declares what happens at the boundary") 4561 4562 if fill is not None: 4563 if channel is None: 4564 raise ValueError("transition(fill=) needs channel= — the fill must land somewhere") 4565 if not hasattr(fill, "events") or not hasattr(fill, "length"): 4566 raise TypeError(f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}") 4567 4568 if mute is not None and beats is None: 4569 beats = float(self.time_signature[0]) # one bar by default 4570 4571 rule = _Transition( 4572 before = before, 4573 fill = fill, 4574 channel = self._resolve_channel(channel) if channel is not None else None, 4575 beat = float(beat), 4576 mute = list(mute) if mute is not None else None, 4577 beats = beats, 4578 drum_note_map = drum_note_map, 4579 device = device, # resolved at fire time — names aren't known until play() 4580 ) 4581 4582 if rule not in self._transitions: 4583 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)
4762 def pattern ( 4763 self, 4764 channel: int, 4765 beats: typing.Optional[float] = None, 4766 bars: typing.Optional[float] = None, 4767 steps: typing.Optional[float] = None, 4768 step_duration: typing.Optional[float] = None, 4769 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4770 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4771 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4772 reschedule_lookahead: float = 1, 4773 voice_leading: bool = False, 4774 device: subsequence.midi_utils.DeviceId = None, 4775 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4776 min_energy: typing.Optional[float] = None, 4777 ) -> typing.Callable: 4778 4779 """ 4780 Register a function as a repeating MIDI pattern. 4781 4782 The decorated function will be called once per cycle to 'rebuild' its 4783 content. This allows for generative logic that evolves over time. 4784 4785 Two ways to specify pattern length: 4786 4787 - **Duration mode** (default): use ``beats=`` or ``bars=``. 4788 The grid defaults to sixteenth-note resolution. 4789 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 4790 The grid equals the step count, so ``p.hit_steps()`` indices map 4791 directly to steps. 4792 4793 Parameters: 4794 channel: MIDI channel. By default uses 1-based numbering (1-16). 4795 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 4796 0-based numbering (0-15), matching the raw MIDI protocol, instead. 4797 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 4798 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 4799 steps: Step count for step mode. Requires ``step_duration=``. 4800 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 4801 Requires ``steps=``. 4802 drum_note_map: Optional mapping for drum instruments. 4803 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4804 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 4805 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 4806 14-bit parameter numbers (0–16383). Enables string-based names 4807 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 4808 device-specific dictionary (e.g. Sequential Take 5's 4809 ``Osc1FreqFine`` → 9). 4810 reschedule_lookahead: Beats in advance to compute the next cycle. 4811 voice_leading: If True, chords in this pattern will automatically 4812 use inversions that minimize voice movement. 4813 mirrors: Optional list of additional ``(device, channel)`` destinations 4814 to duplicate every event from this pattern onto. Notes, CCs, pitch 4815 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 4816 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 4817 ``device`` is the integer index returned by ``midi_output()`` (0 = 4818 primary). ``channel`` follows this composition's channel-numbering 4819 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 4820 min_energy: Automatic energy gating — the pattern is silent while 4821 the current section's energy (``composition.energy()`` dict, 4822 or the bound Section payload) is below this threshold. 4823 Composes with ``mute()``: a performer mute always wins. 4824 4825 Example: 4826 ```python 4827 @comp.pattern(channel=1, beats=4) 4828 def chords (p): 4829 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 4830 4831 @comp.pattern(channel=1, bars=2) 4832 def long_phrase (p): 4833 ... 4834 4835 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 4836 def riff (p): 4837 p.sequence(steps=[0, 1, 3, 5], pitches=60) 4838 ``` 4839 """ 4840 4841 channel = self._resolve_channel(channel) 4842 4843 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4844 4845 # Resolve device string name to index if possible now; otherwise store 4846 # the raw DeviceId and resolve it in _run() once all devices are open. 4847 resolved_device: subsequence.midi_utils.DeviceId = device 4848 4849 # Mirror-to-self check is only reliable when the primary device is a 4850 # concrete integer at decoration time. ``None`` resolves to device 0 4851 # downstream, so we treat it as 0 here too. Strings are deferred to 4852 # ``_run()`` and we skip the check for them. 4853 primary: typing.Optional[typing.Tuple[int, int]] 4854 if isinstance(resolved_device, str): 4855 primary = None 4856 else: 4857 primary = (resolved_device if resolved_device is not None else 0, channel) 4858 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4859 4860 def decorator (fn: typing.Callable) -> typing.Callable: 4861 4862 """ 4863 Wrap the builder function and register it as a pending pattern. 4864 During live sessions, hot-swap an existing pattern's builder instead. 4865 """ 4866 4867 # Record this declaration so the live-reload deletion diff knows the 4868 # pattern is still present in the source (see _apply_source_async). 4869 self._declared_names.add(fn.__name__) 4870 4871 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 4872 if self._is_live and fn.__name__ in self._running_patterns: 4873 running = self._running_patterns[fn.__name__] 4874 running._builder_fn = fn 4875 running._wants_chord = _fn_has_parameter(fn, "chord") 4876 logger.info(f"Hot-swapped pattern: {fn.__name__}") 4877 return fn 4878 4879 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 4880 # duplicate means two scheduled copies sharing one stream with 4881 # only one reachable by name. Warn loudly at registration. 4882 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 4883 logger.warning( 4884 f"Duplicate pattern name '{fn.__name__}': both copies will be " 4885 f"scheduled, they share one seeded stream, and only one is " 4886 f"reachable by name — rename one of them." 4887 ) 4888 4889 pending = _PendingPattern( 4890 builder_fn = fn, 4891 channel = channel, # already resolved to 0-indexed 4892 length = beat_length, 4893 default_grid = default_grid, 4894 drum_note_map = drum_note_map, 4895 cc_name_map = cc_name_map, 4896 nrpn_name_map = nrpn_name_map, 4897 reschedule_lookahead = reschedule_lookahead, 4898 voice_leading = voice_leading, 4899 # For int/None: resolve immediately. For str: store 0 as 4900 # placeholder; _resolve_pending_devices() fixes it in _run(). 4901 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 4902 raw_device = resolved_device, 4903 mirrors = resolved_mirrors, 4904 min_energy = min_energy, 4905 ) 4906 4907 self._pending_patterns.append(pending) 4908 4909 return fn 4910 4911 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)
4913 def layer ( 4914 self, 4915 *builder_fns: typing.Callable, 4916 channel: int, 4917 beats: typing.Optional[float] = None, 4918 bars: typing.Optional[float] = None, 4919 steps: typing.Optional[float] = None, 4920 step_duration: typing.Optional[float] = None, 4921 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4922 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4923 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4924 reschedule_lookahead: float = 1, 4925 voice_leading: bool = False, 4926 device: subsequence.midi_utils.DeviceId = None, 4927 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4928 ) -> None: 4929 4930 """ 4931 Combine multiple functions into a single MIDI pattern. 4932 4933 This is useful for composing complex patterns out of reusable 4934 building blocks (e.g., a 'kick' function and a 'snare' function). 4935 4936 See ``pattern()`` for the full description of ``beats``, ``bars``, 4937 ``steps``, and ``step_duration``. 4938 4939 Parameters: 4940 builder_fns: One or more pattern builder functions. 4941 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4942 beats: Duration in beats (quarter notes). 4943 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4944 steps: Step count for step mode. Requires ``step_duration=``. 4945 step_duration: Duration of one step in beats. Requires ``steps=``. 4946 drum_note_map: Optional mapping for drum instruments. 4947 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4948 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 4949 parameter numbers. 4950 reschedule_lookahead: Beats in advance to compute the next cycle. 4951 voice_leading: If True, chords use smooth voice leading. 4952 mirrors: Optional list of additional ``(device, channel)`` destinations 4953 to duplicate every event onto. See ``pattern()`` for details. 4954 """ 4955 4956 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4957 4958 # Resolve channel up-front so the mirror-to-self check has the canonical 4959 # primary form to compare against. 4960 resolved_channel = self._resolve_channel(channel) 4961 4962 # See pattern() for the same comment about None / str handling. 4963 primary: typing.Optional[typing.Tuple[int, int]] 4964 if isinstance(device, str): 4965 primary = None 4966 else: 4967 primary = (device if device is not None else 0, resolved_channel) 4968 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4969 4970 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 4971 4972 if wants_chord: 4973 4974 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 4975 4976 for fn in builder_fns: 4977 if _fn_has_parameter(fn, "chord"): 4978 fn(p, chord) 4979 else: 4980 fn(p) 4981 4982 else: 4983 4984 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 4985 4986 for fn in builder_fns: 4987 fn(p) 4988 4989 # Give the merged builder a stable, unique name derived from its 4990 # components so multiple layer() calls don't all register under 4991 # "merged_builder" and collide in _running_patterns (which made 4992 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 4993 # appear in a Python identifier, so this never clashes with a real 4994 # pattern function's name. 4995 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 4996 merged_name = base_name 4997 suffix = 2 4998 4999 # Two layers with the same components (e.g. on different saves of a 5000 # live file) must map to the same names pass-over-pass, while two 5001 # DIFFERENT layers sharing components in one pass must not collide. 5002 while merged_name in self._declared_names: 5003 merged_name = f"{base_name}#{suffix}" 5004 suffix += 1 5005 5006 merged_builder.__name__ = merged_name 5007 5008 # Record the declaration for the live-reload deletion diff, and hot-swap 5009 # in place when this layer is already running so a reload picks up edits 5010 # to the component functions without losing the pattern's cycle count, 5011 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 5012 self._declared_names.add(merged_builder.__name__) 5013 5014 if self._is_live and merged_builder.__name__ in self._running_patterns: 5015 running = self._running_patterns[merged_builder.__name__] 5016 running._builder_fn = merged_builder 5017 running._wants_chord = wants_chord 5018 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 5019 return 5020 5021 pending = _PendingPattern( 5022 builder_fn = merged_builder, 5023 channel = resolved_channel, # already resolved to 0-indexed above 5024 length = beat_length, 5025 default_grid = default_grid, 5026 drum_note_map = drum_note_map, 5027 cc_name_map = cc_name_map, 5028 nrpn_name_map = nrpn_name_map, 5029 reschedule_lookahead = reschedule_lookahead, 5030 voice_leading = voice_leading, 5031 mirrors = resolved_mirrors, 5032 device = 0 if (device is None or isinstance(device, str)) else device, 5033 raw_device = device, 5034 ) 5035 5036 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.
5038 def chords ( 5039 self, 5040 *, 5041 channel: int, 5042 progression: subsequence.progressions.ProgressionSource, 5043 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 5044 bars: typing.Optional[float] = None, 5045 beats: typing.Optional[float] = None, 5046 voicing: subsequence.progressions.VoicingSpec = (3, 4), 5047 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 5048 detached: typing.Optional[float] = None, 5049 root: int = 60, 5050 key: typing.Optional[str] = None, 5051 seed: typing.Optional[int] = None, 5052 device: subsequence.midi_utils.DeviceId = None, 5053 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5054 ) -> subsequence.progressions.Progression: 5055 5056 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 5057 5058 The one-call form of ``p.progression()`` — it registers a pattern on 5059 *channel* that plays *progression* across *bars* (or *beats*), each chord 5060 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 5061 the chords change). It needs no ``composition.harmony()`` call and, with an 5062 explicit chord list or a ``key=``, no composition key either — so a 5063 drums-plus-one-chord-part sketch stays simple. 5064 5065 The progression is realised once, up front, and the same timeline plays every 5066 cycle (a stable phrase). That timeline is returned so you can see exactly what 5067 was chosen — ``print(comp.chords(...))``. 5068 5069 Parameters: 5070 channel: MIDI channel for the chord part. 5071 progression: A chord-graph style name to generate from, or an explicit list 5072 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 5073 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 5074 or ``between(low, high, step=...)``. See ``p.progression()``. 5075 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 5076 composition's time signature. 5077 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 5078 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 5079 detached: Beats of silence before each next chord (``duration = length - detached``). 5080 root: MIDI root the voicings are centred on (e.g. 48 = C3). 5081 key: Key for a generated progression; defaults to the composition key. 5082 seed: Seed for the (otherwise fixed) realisation; defaults to the 5083 composition seed, so the part is reproducible. 5084 device: Optional output-device override. 5085 mirrors: Optional additional ``(device, channel)`` destinations. 5086 5087 Returns: 5088 The realised :class:`~subsequence.progressions.Progression`. 5089 """ 5090 5091 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5092 resolved_channel = self._resolve_channel(channel) 5093 resolved_key = key if key is not None else self.key 5094 5095 rng = random.Random(seed if seed is not None else self._seed) 5096 timeline = subsequence.progressions.realize( 5097 source = progression, 5098 harmonic_rhythm = harmonic_rhythm, 5099 key = resolved_key, 5100 length = beat_length, 5101 rng = rng, 5102 scale = self.scale or "ionian", 5103 ) 5104 5105 captured_root = root 5106 captured_velocity = velocity 5107 captured_detached = detached 5108 captured_voicing = voicing 5109 5110 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5111 5112 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 5113 5114 for chord, start, length in timeline: 5115 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 5116 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 5117 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 5118 5119 # Unique, stable name so multiple chord parts don't collide in 5120 # _running_patterns — including two parts on the SAME channel, which 5121 # get a deterministic #2/#3 suffix in declaration order. 5122 base_name = f"chords@ch{resolved_channel}" 5123 chords_name = base_name 5124 suffix = 2 5125 5126 while chords_name in self._declared_names: 5127 chords_name = f"{base_name}#{suffix}" 5128 suffix += 1 5129 5130 chords_builder.__name__ = chords_name 5131 self._declared_names.add(chords_name) 5132 5133 primary: typing.Optional[typing.Tuple[int, int]] 5134 if isinstance(device, str): 5135 primary = None 5136 else: 5137 primary = (device if device is not None else 0, resolved_channel) 5138 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5139 5140 if self._is_live and chords_builder.__name__ in self._running_patterns: 5141 running = self._running_patterns[chords_builder.__name__] 5142 running._builder_fn = chords_builder 5143 running._wants_chord = False 5144 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 5145 return timeline 5146 5147 pending = _PendingPattern( 5148 builder_fn = chords_builder, 5149 channel = resolved_channel, 5150 length = beat_length, 5151 default_grid = default_grid, 5152 drum_note_map = None, 5153 reschedule_lookahead = 1, 5154 voice_leading = False, 5155 mirrors = resolved_mirrors, 5156 device = 0 if (device is None or isinstance(device, str)) else device, 5157 raw_device = device, 5158 ) 5159 self._pending_patterns.append(pending) 5160 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.
5162 def phrase_part ( 5163 self, 5164 *, 5165 channel: int, 5166 part: typing.Optional[str] = None, 5167 root: int = 60, 5168 bars: typing.Optional[float] = None, 5169 beats: typing.Optional[float] = None, 5170 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 5171 fit: typing.Optional[float] = None, 5172 device: subsequence.midi_utils.DeviceId = None, 5173 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5174 ) -> None: 5175 5176 """Declare a part that plays each section's bound Motif/Phrase. 5177 5178 The one-call consumer for :meth:`section_motifs` — it registers a 5179 pattern on *channel* that walks whatever value is bound to the 5180 current section for *part* (stateless position from the cycle 5181 counter, via ``p.phrase()``). A section with no binding for the 5182 part is **silent** for that part — bind material or don't; no 5183 fallback guessing. 5184 5185 Parameters: 5186 channel: MIDI channel for the part. 5187 part: The part label to read from the registry (``None`` = the 5188 unlabelled binding). 5189 root: Register anchor for degree resolution. 5190 bars / beats: Cycle length of the part (defaults to 4 beats); 5191 the phrase is sliced one cycle window at a time. 5192 velocity: Optional override applied to every note. 5193 fit: Passed through (active with the melody engine stage). 5194 device: Optional output-device override. 5195 mirrors: Optional additional ``(device, channel)`` destinations. 5196 5197 Example:: 5198 5199 composition.section_motifs("verse", verse_line, part="lead") 5200 composition.section_motifs("chorus", chorus_line, part="lead") 5201 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 5202 """ 5203 5204 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5205 resolved_channel = self._resolve_channel(channel) 5206 5207 captured_part = part 5208 captured_root = root 5209 captured_velocity = velocity 5210 captured_fit = fit 5211 5212 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5213 5214 """Walk the current section's bound value (silent when unbound).""" 5215 5216 value = p.section_motif(captured_part) 5217 5218 if value is None: 5219 return # unbound section: silence for this part, by design 5220 5221 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 5222 5223 # Unique, stable name so multiple phrase parts don't collide — 5224 # including two parts on the SAME channel (deterministic #2/#3 5225 # suffixes in declaration order, the chords() convention). 5226 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 5227 phrase_name = base_name 5228 suffix = 2 5229 5230 while phrase_name in self._declared_names: 5231 phrase_name = f"{base_name}#{suffix}" 5232 suffix += 1 5233 5234 phrase_builder.__name__ = phrase_name 5235 self._declared_names.add(phrase_name) 5236 5237 primary: typing.Optional[typing.Tuple[int, int]] 5238 if isinstance(device, str): 5239 primary = None 5240 else: 5241 primary = (device if device is not None else 0, resolved_channel) 5242 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5243 5244 if self._is_live and phrase_builder.__name__ in self._running_patterns: 5245 running = self._running_patterns[phrase_builder.__name__] 5246 running._builder_fn = phrase_builder 5247 running._wants_chord = False 5248 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 5249 return 5250 5251 pending = _PendingPattern( 5252 builder_fn = phrase_builder, 5253 channel = resolved_channel, 5254 length = beat_length, 5255 default_grid = default_grid, 5256 drum_note_map = None, 5257 reschedule_lookahead = 1, 5258 voice_leading = False, 5259 mirrors = resolved_mirrors, 5260 device = 0 if (device is None or isinstance(device, str)) else device, 5261 raw_device = device, 5262 ) 5263 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)
5265 def trigger ( 5266 self, 5267 fn: typing.Callable, 5268 channel: int, 5269 beats: typing.Optional[float] = None, 5270 bars: typing.Optional[float] = None, 5271 steps: typing.Optional[float] = None, 5272 step_duration: typing.Optional[float] = None, 5273 quantize: float = 0, 5274 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 5275 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 5276 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 5277 chord: bool = False, 5278 device: subsequence.midi_utils.DeviceId = None, 5279 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5280 ) -> None: 5281 5282 """ 5283 Trigger a one-shot pattern immediately or on a quantized boundary. 5284 5285 This is useful for real-time response to sensors, OSC messages, or other 5286 external events. The builder function is called immediately with a fresh 5287 PatternBuilder, and the generated events are injected into the queue at 5288 the specified quantize boundary. 5289 5290 The builder function has the same API as a ``@composition.pattern`` 5291 decorated function and can use all PatternBuilder methods: ``p.note()``, 5292 ``p.euclidean()``, ``p.arpeggio()``, and so on. 5293 5294 See ``pattern()`` for the full description of ``beats``, ``bars``, 5295 ``steps``, and ``step_duration``. Default is 1 beat. 5296 5297 Parameters: 5298 fn: The pattern builder function (same signature as ``@comp.pattern``). 5299 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 5300 beats: Duration in beats (quarter notes, default 1). 5301 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 5302 steps: Step count for step mode. Requires ``step_duration=``. 5303 step_duration: Duration of one step in beats. Requires ``steps=``. 5304 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 5305 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 5306 constants from ``subsequence.constants.durations``. 5307 drum_note_map: Optional drum name mapping for this pattern. 5308 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 5309 nrpn_name_map: Optional mapping of NRPN parameter names to 5310 14-bit parameter numbers. 5311 chord: If ``True``, the builder function receives the current chord as 5312 a second parameter (same as ``@composition.pattern``). 5313 mirrors: Optional list of additional ``(device, channel)`` destinations 5314 to fire this one-shot onto in parallel with the primary destination. 5315 5316 Example: 5317 ```python 5318 # Immediate single note (channels are 1-16 by default) 5319 composition.trigger( 5320 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 5321 channel=1 5322 ) 5323 5324 # Quantized fill (next bar) — channel 10 is the GM drum channel 5325 import subsequence.constants.durations as dur 5326 composition.trigger( 5327 lambda p: p.euclidean("snare", pulses=7, velocity=90), 5328 channel=10, 5329 drum_note_map=gm_drums.GM_DRUM_MAP, 5330 quantize=dur.WHOLE 5331 ) 5332 5333 # With chord context — the builder receives the chord as a second 5334 # argument when chord=True. 5335 composition.trigger( 5336 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 5337 channel=1, 5338 quantize=dur.QUARTER, 5339 chord=True 5340 ) 5341 ``` 5342 """ 5343 5344 # Resolve channel numbering 5345 resolved_channel = self._resolve_channel(channel) 5346 5347 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 5348 5349 # Resolve device index — for trigger() this is always concrete by call time, 5350 # so the mirror-to-self check has the full primary tuple available. 5351 resolved_device_idx = self._resolve_device_id(device) 5352 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 5353 5354 # Create a temporary Pattern 5355 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 5356 5357 # Resolve the section context once: the one-shot inherits the section's 5358 # effective key/scale (so a triggered degree resolves like everywhere 5359 # else) and a harmony view at the current playhead (so ChordTone / 5360 # Approach resolve too). 5361 trigger_section = self._form_state.get_section_info() if self._form_state else None 5362 trigger_key, trigger_scale = self._effective_key_scale(trigger_section) 5363 5364 trigger_harmony: typing.Optional[HarmonyView] = None 5365 if not self._harmony_horizon.is_empty: 5366 trigger_harmony = HarmonyView(self._harmony_horizon, self._sequencer.pulse_count / self._sequencer.pulses_per_beat) 5367 5368 # Create a PatternBuilder 5369 builder = subsequence.pattern_builder.PatternBuilder( 5370 pattern=pattern, 5371 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 5372 drum_note_map=drum_note_map, 5373 cc_name_map=cc_name_map, 5374 nrpn_name_map=nrpn_name_map, 5375 section=trigger_section, 5376 bar=self._builder_bar, 5377 conductor=self.conductor, 5378 rng=random.Random(), # Fresh random state for each trigger 5379 tweaks={}, 5380 default_grid=default_grid, 5381 data=self.data, 5382 # A one-shot resolves key-relative content against the same 5383 # effective key/scale as the section it fires into (previously 5384 # omitted entirely — degrees raised even in a keyed composition). 5385 key=trigger_key, 5386 scale=trigger_scale, 5387 time_signature=self.time_signature, 5388 held_notes=self._sequencer._held_notes, 5389 harmony=trigger_harmony, 5390 energy=self._current_energy(trigger_section) 5391 ) 5392 5393 # Call the builder function 5394 try: 5395 5396 current_chord = self.current_chord() if chord else None 5397 5398 if current_chord is not None: 5399 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 5400 fn(builder, injected) 5401 5402 else: 5403 fn(builder) 5404 5405 except Exception: 5406 logger.exception("Error in trigger builder — pattern will be silent") 5407 return 5408 5409 # Calculate the start pulse based on quantize 5410 current_pulse = self._sequencer.pulse_count 5411 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 5412 5413 if quantize == 0: 5414 # Immediate: use current pulse 5415 start_pulse = current_pulse 5416 5417 else: 5418 # Quantize to the next multiple of (quantize * pulses_per_beat) 5419 quantize_pulses = int(quantize * pulses_per_beat) 5420 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 5421 5422 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 )
5443 @property 5444 def is_clock_following (self) -> bool: 5445 5446 """True if either the primary or any additional device is following external clock.""" 5447 5448 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.
5451 def play (self) -> None: 5452 5453 """ 5454 Start the composition. 5455 5456 This call blocks until the program is interrupted (e.g., via Ctrl+C). 5457 It initializes the MIDI hardware, launches the background sequencer, 5458 and begins playback. 5459 """ 5460 5461 try: 5462 asyncio.run(self._run()) 5463 5464 except KeyboardInterrupt: 5465 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.
5468 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 5469 5470 """Render the composition to a MIDI file without real-time playback. 5471 5472 Runs the sequencer as fast as possible (no timing delays) and stops 5473 when the first active limit is reached. The result is saved as a 5474 standard MIDI file that can be imported into any DAW. 5475 5476 All patterns, scheduled callbacks, and harmony logic run exactly as 5477 they would during live playback — BPM transitions, generative fills, 5478 and probabilistic gates all work in render mode. The only difference 5479 is that time is simulated rather than wall-clock driven. 5480 5481 Parameters: 5482 bars: Number of bars to render, or ``None`` for no bar limit 5483 (default ``None``). When both *bars* and *max_minutes* are 5484 active, playback stops at whichever limit is reached first. 5485 filename: Output MIDI filename (default ``"render.mid"``). 5486 max_minutes: Safety cap on the length of rendered MIDI in minutes 5487 (default ``60.0``). Pass ``None`` to disable the time 5488 cap — you must then provide an explicit *bars* value. 5489 5490 Raises: 5491 ValueError: If both *bars* and *max_minutes* are ``None``, which 5492 would produce an infinite render. 5493 5494 Examples: 5495 ```python 5496 # Default: renders up to 60 minutes of MIDI content. 5497 composition.render() 5498 5499 # Render exactly 64 bars (time cap still active as backstop). 5500 composition.render(bars=64, filename="demo.mid") 5501 5502 # Render up to 5 minutes of an infinite generative composition. 5503 composition.render(max_minutes=5, filename="five_min.mid") 5504 5505 # Remove the time cap — must supply bars instead. 5506 composition.render(bars=128, max_minutes=None, filename="long.mid") 5507 ``` 5508 """ 5509 5510 if bars is None and max_minutes is None: 5511 raise ValueError( 5512 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 5513 "Passing both as None would produce an infinite render." 5514 ) 5515 5516 self._sequencer.recording = True 5517 self._sequencer.record_filename = filename 5518 self._sequencer.render_mode = True 5519 self._sequencer.render_bars = bars if bars is not None else 0 5520 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 5521 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")
404@dataclasses.dataclass(frozen=True) 405class Motif: 406 407 """ 408 An immutable musical figure: timed note events + control gestures + a length in beats. 409 410 Construct via the classmethods (:meth:`degrees`, :meth:`notes`, 411 :meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture 412 constructors, or :meth:`from_events`) rather than positionally. 413 ``length`` is explicit — a trailing rest is meaningful. 414 """ 415 416 events: typing.Tuple[MotifEvent, ...] 417 length: float 418 controls: typing.Tuple[ControlEvent, ...] = () 419 fit: typing.Optional[float] = None # placement default for the fit dial; set by generate() 420 421 def __post_init__ (self) -> None: 422 423 """Validate, and normalise both streams to canonical order.""" 424 425 if self.length < 0: 426 raise ValueError(f"Motif length must be non-negative — got {self.length}") 427 428 object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key))) 429 object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key))) 430 431 # ── constructors ──────────────────────────────────────────────────── 432 433 @classmethod 434 def empty (cls) -> "Motif": 435 436 """The empty motif (zero events, zero length) — the identity for ``then``.""" 437 438 return cls(events=(), length=0.0) 439 440 @classmethod 441 def from_events ( 442 cls, 443 events: typing.Iterable[MotifEvent], 444 length: typing.Optional[float] = None, 445 controls: typing.Iterable[ControlEvent] = (), 446 ) -> "Motif": 447 448 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 449 450 events = tuple(events) 451 controls = tuple(controls) 452 453 return cls( 454 events = events, 455 length = _computed_length(events, controls) if length is None else length, 456 controls = controls, 457 ) 458 459 @classmethod 460 def _from_sequence ( 461 cls, 462 pitches: typing.List[PitchSpec], 463 beats: typing.Optional[typing.List[float]], 464 velocities: typing.Any, 465 durations: typing.Any, 466 probabilities: typing.Any, 467 length: typing.Optional[float], 468 ) -> "Motif": 469 470 """Shared core: one event per element, None = rest (slot still advances).""" 471 472 n = len(pitches) 473 onsets = list(beats) if beats is not None else [float(i) for i in range(n)] 474 475 if len(onsets) != n: 476 raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match") 477 478 velocity_list = _expand("velocities", velocities, n) 479 duration_list = _expand("durations", durations, n) 480 probability_list = _expand("probabilities", probabilities, n) 481 482 events = tuple( 483 MotifEvent( 484 beat = float(onsets[i]), 485 pitch = pitches[i], 486 velocity = velocity_list[i], 487 duration = float(duration_list[i]), 488 probability = float(probability_list[i]), 489 ) 490 for i in range(n) 491 if pitches[i] is not None 492 ) 493 494 return cls( 495 events = events, 496 length = _computed_length(events, ()) if length is None else float(length), 497 ) 498 499 @classmethod 500 def degrees ( 501 cls, 502 degrees: typing.List[typing.Union[int, Degree, None]], 503 beats: typing.Optional[typing.List[float]] = None, 504 velocities: typing.Any = _DEFAULT_VELOCITY, 505 durations: typing.Any = 1.0, 506 probabilities: typing.Any = 1.0, 507 length: typing.Optional[float] = None, 508 ) -> "Motif": 509 510 """ 511 A melody written as 1-based scale degrees, one per beat by default. 512 513 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 514 rest (the beat slot still advances), or :class:`Degree` for octave/ 515 chromatic detail. Resolved against key + scale at placement. 516 Durations default to a full beat (each note holds its slot). 517 """ 518 519 converted: typing.List[PitchSpec] = [] 520 521 for element in degrees: 522 if isinstance(element, int): 523 if element > _MAX_PLAUSIBLE_DEGREE: 524 raise ValueError( 525 f"Degree {element} is implausibly large — scale degrees are 1-based " 526 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 527 ) 528 converted.append(Degree(element)) 529 elif isinstance(element, Degree) or element is None: 530 converted.append(element) 531 else: 532 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 533 534 return cls._from_sequence(converted, beats, velocities, durations, probabilities, length) 535 536 @classmethod 537 def notes ( 538 cls, 539 notes: typing.List[typing.Union[int, None]], 540 beats: typing.Optional[typing.List[float]] = None, 541 velocities: typing.Any = _DEFAULT_VELOCITY, 542 durations: typing.Any = 1.0, 543 probabilities: typing.Any = 1.0, 544 length: typing.Optional[float] = None, 545 ) -> "Motif": 546 547 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 548 549 for element in notes: 550 # bool is a subclass of int, but True/False are never MIDI notes. 551 if isinstance(element, bool) or not (isinstance(element, int) or element is None): 552 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 553 554 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length) 555 556 @classmethod 557 def hits ( 558 cls, 559 pitch: typing.Union[int, str], 560 beats: typing.List[float], 561 length: typing.Optional[float] = None, 562 velocities: typing.Any = _DEFAULT_VELOCITY, 563 durations: typing.Any = 0.1, 564 probabilities: typing.Any = 1.0, 565 ) -> "Motif": 566 567 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 568 569 return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length) 570 571 @classmethod 572 def steps ( 573 cls, 574 steps: typing.List[int], 575 pitches: typing.Any, 576 velocities: typing.Any = _DEFAULT_VELOCITY, 577 durations: typing.Any = 0.1, 578 probabilities: typing.Any = 1.0, 579 step_duration: float = 0.25, 580 length: typing.Optional[float] = None, 581 ) -> "Motif": 582 583 """ 584 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 585 grid indices (sixteenths by default), ``pitches`` a scalar or 586 parallel list of MIDI ints or drum names. 587 """ 588 589 n = len(steps) 590 pitch_list = _expand("pitches", pitches, n) 591 onsets = [s * step_duration for s in steps] 592 593 if length is None and n: 594 length = float(math.ceil((max(steps) + 1) * step_duration)) 595 596 return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length) 597 598 @classmethod 599 def euclidean ( 600 cls, 601 pulses: int, 602 steps: int, 603 pitch: typing.Union[int, str], 604 length: float = 4.0, 605 velocities: typing.Any = _DEFAULT_VELOCITY, 606 durations: typing.Any = 0.1, 607 probabilities: typing.Any = 1.0, 608 ) -> "Motif": 609 610 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 611 612 # bool is a subclass of int, but True/False are never MIDI notes. 613 if isinstance(pitch, bool): 614 raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}") 615 616 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 617 # It validates pulses first, so pulses > steps still raises clearly. 618 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 619 620 if steps <= 0: 621 # A grid of zero steps holds no onsets — an empty motif of the given 622 # length, matching pulses=0 on a real grid (the empty-input policy). 623 return cls._from_sequence([], [], velocities, durations, probabilities, length) 624 625 step_duration = length / steps 626 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 627 628 return cls._from_sequence( 629 [pitch] * len(onsets), 630 onsets, 631 velocities, durations, probabilities, length, 632 ) 633 634 @classmethod 635 def preset ( 636 cls, 637 name: str, 638 pitch: typing.Optional[typing.Union[int, str]] = None, 639 length: float = 4.0, 640 velocities: typing.Any = _DEFAULT_VELOCITY, 641 durations: typing.Any = 0.1, 642 probabilities: typing.Any = 1.0, 643 ) -> "Motif": 644 645 """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. 646 647 Looks a curated timeline up in the world-rhythm table (clave family, 648 West-African bell patterns, tresillo/cinquillo, samba) and lays its 649 onsets across *length* beats. Onset positions are exact pulse indices 650 from Toussaint's "The Geometry of Musical Rhythm"; each preset declares 651 its own grid (16 for the clave/4-4 timelines, 12 for the bell 652 patterns) and a default drum voice. 653 654 Parameters: 655 name: A preset name (``KeyError``-style ValueError lists them all). 656 pitch: The voice — a drum name or MIDI int; defaults to the 657 preset's General-MIDI voice (``"claves"``, ``"cowbell"``, 658 ``"side_stick"``, ``"low_conga"``), so it sounds against the 659 standard GM drum map without a ``pitch=``. 660 length: Total beats the cycle spans (4 = one common-time bar). 661 velocities / durations / probabilities: The parallel-list params. 662 663 Returns: 664 A drum/pitched :class:`Motif` of the timeline's onsets. 665 666 Raises: 667 ValueError: If *name* is not a known preset. 668 669 Example: 670 ```python 671 clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" 672 bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse 673 ``` 674 """ 675 676 if name not in _WORLD_RHYTHMS: 677 known = ", ".join(sorted(_WORLD_RHYTHMS)) 678 raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") 679 680 steps, grid, voice = _WORLD_RHYTHMS[name] 681 682 return cls.steps( 683 steps = list(steps), 684 pitches = pitch if pitch is not None else voice, 685 velocities = velocities, 686 durations = durations, 687 probabilities = probabilities, 688 step_duration = length / grid, 689 length = length, 690 ) 691 692 # ── control-gesture constructors (mirror the pattern_midi verbs) ──── 693 694 @classmethod 695 def _control_writes ( 696 cls, 697 signal: ControlSignal, 698 values: typing.List[float], 699 beats: typing.List[float], 700 length: typing.Optional[float], 701 probabilities: typing.Any = 1.0, 702 ) -> "Motif": 703 704 """Shared core for discrete control writes.""" 705 706 if len(values) != len(beats): 707 raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match") 708 709 probability_list = _expand("probabilities", probabilities, len(values)) 710 711 controls = tuple( 712 ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i])) 713 for i in range(len(values)) 714 ) 715 716 return cls( 717 events = (), 718 length = _computed_length((), controls) if length is None else float(length), 719 controls = controls, 720 ) 721 722 @classmethod 723 def _control_ramp ( 724 cls, 725 signal: ControlSignal, 726 start: float, 727 end: float, 728 beat_start: float, 729 beat_end: typing.Optional[float], 730 shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"], 731 length: typing.Optional[float], 732 probability: float = 1.0, 733 ) -> "Motif": 734 735 """Shared core for shaped control ramps.""" 736 737 if beat_end is None: 738 if length is None: 739 raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)") 740 beat_end = float(length) 741 742 if beat_end <= beat_start: 743 raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})") 744 745 controls = ( 746 ControlEvent( 747 beat = float(beat_start), 748 signal = signal, 749 start = float(start), 750 end = float(end), 751 span = float(beat_end) - float(beat_start), 752 shape = shape, 753 probability = probability, 754 ), 755 ) 756 757 return cls( 758 events = (), 759 length = float(math.ceil(beat_end)) if length is None else float(length), 760 controls = controls, 761 ) 762 763 @classmethod 764 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": 765 766 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 767 768 return cls._control_writes(CC(control), list(values), list(beats), length, probabilities) 769 770 @classmethod 771 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["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 772 773 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 774 775 return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability) 776 777 @classmethod 778 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 779 780 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 781 782 return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities) 783 784 @classmethod 785 def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 786 787 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 788 789 return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability) 790 791 @classmethod 792 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": 793 794 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 795 796 return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 797 798 @classmethod 799 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["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 800 801 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 802 803 return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 804 805 @classmethod 806 def rpn (cls, parameter: typing.Union[int, "subsequence.declarations.RpnParameter"], 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": 807 808 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 809 810 return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 811 812 @classmethod 813 def rpn_ramp (cls, parameter: typing.Union[int, "subsequence.declarations.RpnParameter"], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 814 815 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 816 817 return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 818 819 @classmethod 820 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 821 822 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 823 824 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities) 825 826 @classmethod 827 def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 828 829 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 830 831 return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability) 832 833 # ── the algebra ───────────────────────────────────────────────────── 834 835 def then (self, other: "Motif") -> "Motif": 836 837 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 838 839 if not isinstance(other, Motif): 840 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 841 842 return Motif( 843 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 844 length = self.length + other.length, 845 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 846 # fit is a dial, not content: keep ours, inherit the other's when 847 # we have none — join()/tiling folds from empty() (fit=None), and 848 # must not silently strip a generated motif's chord-snapping. 849 fit = self.fit if self.fit is not None else other.fit, 850 ) 851 852 @classmethod 853 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 854 855 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 856 857 result = cls.empty() 858 859 for m in motifs: 860 result = result.then(m) 861 862 return result 863 864 @classmethod 865 def generate ( 866 cls, 867 rhythm: typing.Any, 868 length: typing.Optional[float] = None, 869 scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, 870 contour: typing.Optional[str] = None, 871 end_on: typing.Optional[typing.Union[int, Degree]] = None, 872 cadence: typing.Optional[str] = None, 873 pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, 874 max_pitches: typing.Optional[int] = None, 875 velocities: typing.Any = _DEFAULT_VELOCITY, 876 durations: typing.Any = 0.25, 877 seed: typing.Optional[int] = None, 878 rng: typing.Optional[random.Random] = None, 879 state: typing.Optional[typing.Any] = None, 880 nir_strength: float = 0.5, 881 pitch_diversity: float = 0.6, 882 tessitura_strength: float = 0.6, 883 ) -> "Motif": 884 885 """Generate a melodic motif — rhythm first, pitches walked, a value out. 886 887 The melody engine emitting a value: you give the **rhythm** (an onset 888 list in beats, or another motif whose rhythm to borrow — cross-pattern 889 rhythm reuse is shared values); the engine walks pitches over it 890 through the soft scoring factors (NIR expectation, contour envelope, 891 tessitura regression, diversity), honouring any pins. 892 893 The result emits **scale degrees** (resolved at placement against the 894 composition key/scale), so a generated hook transposes, varies, and 895 develops like a hand-written one. ``scale=`` constrains *candidate 896 choice only*: a name or interval list masks which pitches the walk 897 may use, spelled relative to its best-fit reference (major or minor) 898 — bind it in a composition whose scale matches that family and 899 resolution is exact. An explicit MIDI pitch pool (a list of note 900 numbers) switches to absolute output (the sieve/atonal path). 901 902 Parameters: 903 rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif 904 (its onsets are borrowed). 905 length: Motif length in beats; defaults to the onsets rounded 906 up to a whole 4-beat bar. 907 scale: A scale name, an interval list, or an explicit MIDI 908 pitch pool. ``None`` = the plain seven degrees. 909 contour: Envelope shaping the line's height over its span — 910 ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. 911 end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. 912 Degree semantics: raises with an explicit MIDI pool (pin the 913 exact note instead). 914 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 915 ``"fakeout"``) — the line closes on that cadence's melodic 916 degree (1 for the full closes and the fakeout, 5 for the 917 open half). Sugar for ``end_on=``; conflicts with it, and 918 raises with an explicit MIDI pool like ``end_on=``. 919 pins: ``{position: degree}`` — 1-based note positions (``-1`` = 920 the last, the Python idiom); the engine fills between. With 921 an explicit MIDI pool there are no degrees to read, so each 922 pin is the exact MIDI note to play (``Degree`` pins raise). 923 max_pitches: Cap on distinct pitches (a tight pool is a hook); 924 keeps the most central candidates. 925 velocities / durations: Scalar or per-note list (the parallel- 926 list convention). 927 seed: Seed for the walk (required or warned — module-level 928 nondeterminism breaks live reload). 929 rng: Explicit stream (overrides ``seed``). 930 state: A ``MelodicState`` whose dials, scoring factors, and 931 melodic history seed the walk. It is **copied** — building 932 a value never mutates a module-level live object. The 933 candidate pool is not carried over: it is always rebuilt 934 from ``scale=`` (pass an explicit pool there instead), 935 though the state's key still sets the tonic that the NIR 936 closure rule lands on. 937 nir_strength / pitch_diversity / tessitura_strength: The walk's 938 dials when no ``state`` is given. 939 940 Example: 941 ```python 942 hook = subsequence.Motif.generate( 943 rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", 944 contour="arch", end_on=1, seed=7, 945 ) 946 ``` 947 """ 948 949 import subsequence.melodic_state 950 951 onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm] 952 953 if cadence is not None: 954 if end_on is not None: 955 raise ValueError("cadence= already names the close degree — it conflicts with end_on=") 956 end_on = subsequence.cadences.cadence_formula(cadence).close_degree 957 958 if not onsets: 959 raise ValueError("generate() needs at least one onset — the rhythm comes first") 960 if sorted(onsets) != onsets: 961 raise ValueError("rhythm onsets must ascend") 962 963 if length is None: 964 length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) 965 if onsets[-1] >= length: 966 raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}") 967 968 if rng is None: 969 if seed is None: 970 warnings.warn( 971 "generate() without seed= is nondeterministic — pass seed= so the " 972 "value survives live reload", 973 stacklevel = 2, 974 ) 975 rng = random.Random() 976 else: 977 rng = random.Random(seed) 978 979 # --- The candidate pool ------------------------------------------------ 980 absolute_pool: typing.Optional[typing.List[int]] = None 981 intervals: typing.List[int] 982 983 if scale is None: 984 intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) 985 elif isinstance(scale, str): 986 intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) 987 else: 988 values = [int(v) for v in scale] 989 if values and (min(values) != 0 or max(values) > 11): 990 absolute_pool = sorted(values) # an explicit MIDI pool: absolute output 991 intervals = [] 992 else: 993 intervals = sorted(set(values)) 994 995 # Best-fit reference scale for degree spelling: whichever of major/ 996 # minor contains more of the pool (ties to major). Bound under a 997 # matching composition scale, resolution is exact. 998 if absolute_pool is None: 999 ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) 1000 aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) 1001 reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian" 1002 reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name)) 1003 1004 # --- The walking state (copied, never mutated in place) ---------------- 1005 if state is not None: 1006 walker = state.clone() 1007 walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a 1008 # note, so the walker never rests (and never falls 1009 # back to a stuck repeat) — rests come from the rhythm 1010 else: 1011 walker = subsequence.melodic_state.MelodicState( 1012 nir_strength = nir_strength, 1013 pitch_diversity = pitch_diversity, 1014 tessitura_strength = tessitura_strength, 1015 chord_weight = 0.0, # values have no chord context; fit applies at placement 1016 ) 1017 1018 if absolute_pool is not None: 1019 walker.set_pool(absolute_pool) 1020 else: 1021 # Offsets over ~1.5 octaves anchored at 60 — register is decided 1022 # at placement (root=), so the anchor is arbitrary and erased. 1023 walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19]) 1024 1025 if max_pitches is not None: 1026 if max_pitches < 1: 1027 raise ValueError("max_pitches must be at least 1") 1028 pool = sorted(walker._pitch_pool) 1029 centre = pool[len(pool) // 2] 1030 walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches])) 1031 1032 # --- Pins --------------------------------------------------------------- 1033 resolved_pins: typing.Dict[int, int] = {} 1034 combined = dict(pins or {}) 1035 1036 # cadence=/end_on= name scale DEGREES — meaningless against an explicit 1037 # MIDI pool, where they would silently land as raw (sub-audio) note 1038 # numbers. 1039 if absolute_pool is not None and end_on is not None: 1040 raise ValueError( 1041 "cadence=/end_on= name scale degrees, but this motif uses an " 1042 "explicit MIDI pool — pin the exact closing note instead: " 1043 "pins={-1: <midi note>}" 1044 ) 1045 1046 if end_on is not None: 1047 if -1 in combined or len(onsets) in combined: 1048 raise ValueError("end_on conflicts with a pin on the last note — they name the same position") 1049 combined[-1] = end_on 1050 1051 for pin_position, pin_spec in combined.items(): 1052 if not isinstance(pin_position, int) or isinstance(pin_position, bool): 1053 raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}") 1054 index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position 1055 if not 0 <= index < len(onsets): 1056 raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm") 1057 if absolute_pool is not None: 1058 # A raw int pins the exact MIDI note; a Degree has no meaning 1059 # here (the pool defines no scale to read it against). 1060 if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): 1061 raise ValueError( 1062 f"pin {pin_spec!r} is a scale degree, but this motif uses an " 1063 "explicit MIDI pool — pin the exact MIDI note instead " 1064 "(e.g. pins={-1: 52})" 1065 ) 1066 resolved_pins[index] = int(pin_spec) 1067 else: 1068 degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) 1069 step_index = (degree.step - 1) % len(reference) 1070 carry = (degree.step - 1) // len(reference) 1071 resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma 1072 1073 # --- The walk ----------------------------------------------------------- 1074 envelopes: typing.Dict[str, typing.Callable[[float], float]] = { 1075 "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), 1076 "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), 1077 "ascending": lambda pos: 0.1 + 0.85 * pos, 1078 "descending": lambda pos: 0.95 - 0.85 * pos, 1079 } 1080 1081 if contour is not None and contour not in envelopes: 1082 known = ", ".join(sorted(envelopes)) 1083 raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") 1084 1085 chosen_pitches: typing.List[int] = [] 1086 1087 for index, onset in enumerate(onsets): 1088 1089 if index in resolved_pins: 1090 pitch = resolved_pins[index] 1091 walker.record(pitch) # pins enter the NIR context like chosen notes 1092 else: 1093 span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 1094 target = envelopes[contour](span_position) if contour is not None else None 1095 picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target) 1096 pitch = picked if picked is not None else walker._pitch_pool[0] 1097 1098 chosen_pitches.append(pitch) 1099 1100 # --- Emission ------------------------------------------------------------ 1101 velocity_values = _expand("velocities", velocities, len(onsets)) 1102 duration_values = _expand("durations", durations, len(onsets)) 1103 1104 events = [] 1105 1106 for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): 1107 1108 spec: PitchSpec 1109 1110 if absolute_pool is not None: 1111 spec = pitch 1112 else: 1113 offset = pitch - 60 1114 octave, pc = divmod(offset, 12) 1115 if pc in reference: 1116 spec = Degree(reference.index(pc) + 1, octave = octave) 1117 elif (pc + 1) % 12 in reference and pc + 1 <= 11: 1118 spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1) 1119 else: 1120 spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1) 1121 1122 events.append(MotifEvent( 1123 beat = onset, 1124 pitch = spec, 1125 velocity = velocity_values[index], 1126 duration = float(duration_values[index]), 1127 )) 1128 1129 return cls(events = tuple(events), length = float(length), fit = 0.7) 1130 1131 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 1132 1133 """ 1134 Parallel merge (the spelled form of ``&``): event union, length = max. 1135 1136 No implicit tiling — a short gesture stacked under a long figure 1137 plays once. Phrase operands flatten first. 1138 """ 1139 1140 if isinstance(other, Phrase): 1141 merged = other.flatten() 1142 elif isinstance(other, Motif): 1143 merged = other 1144 else: 1145 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 1146 1147 return Motif( 1148 events = self.events + merged.events, 1149 length = max(self.length, merged.length), 1150 controls = self.controls + merged.controls, 1151 fit = self.fit, 1152 ) 1153 1154 def slice (self, start: float, end: float) -> "Motif": 1155 1156 """ 1157 A window onto the motif, on its own authority: events starting outside 1158 are dropped; durations and ramp spans truncate at the cut (a truncated 1159 ramp ends at its interpolated cut value). Beats shift so the window 1160 starts at 0. 1161 """ 1162 1163 if end <= start: 1164 raise ValueError(f"slice end ({end}) must be after start ({start})") 1165 1166 events = tuple( 1167 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 1168 for e in self.events 1169 if start <= e.beat < end 1170 ) 1171 1172 controls = [] 1173 1174 for c in self.controls: 1175 if not (start <= c.beat < end): 1176 continue 1177 if c.end is not None and c.beat + c.span > end: 1178 kept = end - c.beat 1179 controls.append(dataclasses.replace( 1180 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 1181 )) 1182 else: 1183 controls.append(dataclasses.replace(c, beat=c.beat - start)) 1184 1185 return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit) 1186 1187 def __add__ (self, other: typing.Any) -> "Phrase": 1188 1189 """``a + b`` — sequential: a two-segment Phrase (segmentation preserved).""" 1190 1191 if isinstance(other, Motif): 1192 return Phrase((self, other)) 1193 1194 return NotImplemented 1195 1196 def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]: 1197 1198 """``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty.""" 1199 1200 if not isinstance(count, int): 1201 return NotImplemented 1202 if count < 0: 1203 raise ValueError(f"Repetition count must be non-negative — got {count}") 1204 if count == 0: 1205 return Motif.empty() 1206 if count == 1: 1207 return self 1208 1209 return Phrase((self,) * count) 1210 1211 __rmul__ = __mul__ 1212 1213 def __and__ (self, other: typing.Any) -> "Motif": 1214 1215 """``a & b`` — parallel merge; the spelled form is :meth:`stack`.""" 1216 1217 if isinstance(other, (Motif, Phrase)): 1218 return self.stack(other) 1219 1220 return NotImplemented 1221 1222 # ── transforms (pure; control gestures ride time, ignore pitch) ───── 1223 1224 def reverse (self) -> "Motif": 1225 1226 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 1227 1228 events = tuple( 1229 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 1230 for e in self.events 1231 ) 1232 controls = tuple( 1233 dataclasses.replace( 1234 c, 1235 beat = max(0.0, self.length - c.beat - c.span), 1236 start = c.start if c.end is None else c.end, 1237 end = c.end if c.end is None else c.start, 1238 ) 1239 for c in self.controls 1240 ) 1241 1242 return Motif(events=events, length=self.length, controls=controls, fit=self.fit) 1243 1244 def rotate (self, beats: float) -> "Motif": 1245 1246 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 1247 1248 if self.length == 0: 1249 return self 1250 1251 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 1252 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 1253 1254 return Motif(events=events, length=self.length, controls=controls, fit=self.fit) 1255 1256 def stretch (self, factor: float) -> "Motif": 1257 1258 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 1259 1260 if factor <= 0: 1261 raise ValueError(f"Stretch factor must be positive — got {factor}") 1262 1263 events = tuple( 1264 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 1265 for e in self.events 1266 ) 1267 controls = tuple( 1268 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 1269 for c in self.controls 1270 ) 1271 1272 return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit) 1273 1274 def quantize (self, grid: float) -> "Motif": 1275 1276 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). 1277 1278 An onset exactly midway between grid lines snaps LATER (round half 1279 up) — every midpoint moves the same way, the predictable behaviour 1280 for a musician. (Python's own ``round()`` is half-to-even, which 1281 made exact midpoints snap in alternating directions.) 1282 """ 1283 1284 if grid <= 0: 1285 raise ValueError(f"Quantize grid must be positive — got {grid}") 1286 1287 events = tuple( 1288 dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) 1289 for e in self.events 1290 ) 1291 1292 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1293 1294 def accent (self, beat: float, amount: int = 20) -> "Motif": 1295 1296 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 1297 1298 def boost (velocity: subsequence.declarations.VelocityValue) -> subsequence.declarations.VelocityValue: 1299 # Clamp both ends: a negative amount (a de-accent) must not store 1300 # a velocity below 1, which MIDI cannot play. 1301 if isinstance(velocity, (tuple, list)): 1302 return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount))) 1303 return max(1, min(127, velocity + amount)) 1304 1305 events = tuple( 1306 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 1307 for e in self.events 1308 ) 1309 1310 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1311 1312 def with_velocity (self, velocity: subsequence.declarations.VelocityValue) -> "Motif": 1313 1314 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 1315 1316 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 1317 1318 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1319 1320 def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random, origin: typing.Optional[str]) -> PitchSpec: 1321 1322 """One varied pitch: a small melodic nudge that always changes the note. 1323 1324 Degrees move by scale steps, MIDI ints by semitones, chord tones by 1325 index; an Approach's target is nudged. Drum names raise — a varied 1326 drum is a different instrument, not a variation — and so does a 1327 captured drum, which arrives as a number carrying its ``origin``. 1328 """ 1329 1330 _refuse_captured_drum(origin, "vary()", "varied") 1331 1332 if isinstance(pitch, Degree): 1333 steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1] 1334 return dataclasses.replace(pitch, step = rng.choice(steps)) 1335 if isinstance(pitch, ChordTone): 1336 indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1] 1337 return ChordTone(rng.choice(indices), octave = pitch.octave) 1338 if isinstance(pitch, Approach): 1339 nudged = self._nudged_pitch(pitch.target, rng, None) # an Approach is authored, never captured 1340 if not isinstance(nudged, (int, Degree, ChordTone)): 1341 raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content") 1342 return Approach(nudged) 1343 if isinstance(pitch, int): 1344 return pitch + rng.choice((-2, -1, 1, 2)) 1345 1346 raise TypeError( 1347 f"vary() moves pitches — {type(pitch).__name__} content cannot vary " 1348 "(a varied drum is a different instrument)" 1349 ) 1350 1351 def vary ( 1352 self, 1353 notes: int = 1, 1354 position: str = "end", 1355 seed: typing.Optional[int] = None, 1356 rng: typing.Optional[random.Random] = None, 1357 keep_contour: bool = False, 1358 ) -> "Motif": 1359 1360 """Replace a few pitches, preserving the rhythm — the smallest variation. 1361 1362 Rhythm, velocities, durations, rests, and control gestures are 1363 untouched; only the chosen notes' pitches move (by a small melodic 1364 nudge: scale steps for degrees, semitones for MIDI ints). 1365 1366 Parameters: 1367 notes: How many pitched notes to vary (clamped to what exists). 1368 position: Which notes — ``"end"`` (the tail, the default), 1369 ``"start"``, or ``"anywhere"`` (drawn from the stream). 1370 seed: Seed for the variation. A standalone vary without a seed 1371 warns — module-level nondeterminism breaks live reload. 1372 rng: An explicit random stream (overrides ``seed``; used by 1373 recipe machinery). 1374 keep_contour: When True, the variation preserves the line's 1375 CSEG — every varied note keeps its rank relations with 1376 every other note, so the melodic shape is identical (the 1377 motif-identity guard). Where no nudge can preserve the 1378 contour, that note stays unchanged — shape wins over 1379 motion. 1380 1381 Example: 1382 ```python 1383 answer = call.vary(notes=1, seed=4) # same figure, new tail note 1384 ``` 1385 """ 1386 1387 if notes < 0: 1388 raise ValueError(f"notes must be at least 0, got {notes}") 1389 if position not in ("end", "start", "anywhere"): 1390 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 1391 1392 if rng is None: 1393 if seed is None: 1394 warnings.warn( 1395 "vary() without seed= is nondeterministic — pass seed= so the " 1396 "value survives live reload", 1397 stacklevel = 2, 1398 ) 1399 rng = random.Random() 1400 else: 1401 rng = random.Random(seed) 1402 1403 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1404 count = min(notes, len(pitched_indices)) 1405 1406 if count == 0: 1407 return self 1408 1409 if position == "end": 1410 chosen = pitched_indices[-count:] 1411 elif position == "start": 1412 chosen = pitched_indices[:count] 1413 else: 1414 chosen = sorted(rng.sample(pitched_indices, count)) 1415 1416 events = list(self.events) 1417 1418 for index in chosen: 1419 if keep_contour: 1420 replacement = self._contour_safe_nudge(events, index, pitched_indices, rng) 1421 if replacement is not None: 1422 events[index] = dataclasses.replace(events[index], pitch = replacement) 1423 else: 1424 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng, events[index].origin)) 1425 1426 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) 1427 1428 @staticmethod 1429 def _rank_value (pitch: PitchSpec) -> float: 1430 1431 """A comparable height for contour ranking (uniform content only).""" 1432 1433 if isinstance(pitch, Degree): 1434 return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma 1435 if isinstance(pitch, ChordTone): 1436 return pitch.octave * 4 + pitch.index 1437 if isinstance(pitch, int): 1438 return float(pitch) 1439 1440 raise TypeError(f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height") 1441 1442 def _contour_safe_nudge ( 1443 self, 1444 events: typing.List[MotifEvent], 1445 index: int, 1446 pitched_indices: typing.List[int], 1447 rng: random.Random, 1448 ) -> typing.Optional[PitchSpec]: 1449 1450 """A nudge for events[index] that preserves its CSEG rank relations. 1451 1452 Candidates are the usual small nudges, filtered to those keeping the 1453 note's above/below/equal relation to every other pitched note. One 1454 rng draw happens regardless (stream stability); ``None`` means no 1455 candidate preserves the shape — leave the note alone. 1456 """ 1457 1458 _refuse_captured_drum(events[index].origin, "vary()", "varied") 1459 1460 pitch = events[index].pitch 1461 1462 if isinstance(pitch, Degree): 1463 candidates: typing.List[PitchSpec] = [ 1464 dataclasses.replace(pitch, step = pitch.step + delta) 1465 for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1 1466 ] 1467 elif isinstance(pitch, int): 1468 candidates = [pitch + delta for delta in (-2, -1, 1, 2)] 1469 else: 1470 raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content") 1471 1472 original = self._rank_value(pitch) 1473 others = [ 1474 (self._rank_value(events[other].pitch), other) 1475 for other in pitched_indices if other != index 1476 ] 1477 1478 def preserves (candidate: PitchSpec) -> bool: 1479 height = self._rank_value(candidate) 1480 for other_height, _ in others: 1481 before = (original > other_height) - (original < other_height) 1482 after = (height > other_height) - (height < other_height) 1483 if before != after: 1484 return False 1485 return True 1486 1487 surviving = [candidate for candidate in candidates if preserves(candidate)] 1488 1489 # One draw either way, so adding keep_contour never shifts the stream 1490 # consumed by the notes around this one. 1491 draw = rng.random() 1492 1493 if not surviving: 1494 return None 1495 1496 return surviving[int(draw * len(surviving)) % len(surviving)] 1497 1498 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 1499 1500 """Call → response: re-aim the tail to a stable degree. 1501 1502 The classic consequent move — the figure repeats but its last pitched 1503 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1504 or a full ``Degree`` for register control). Everything else — 1505 rhythm, the other pitches, velocities, controls — is untouched. 1506 1507 Degree content only: absolute MIDI has no degrees to re-aim (build 1508 the call with ``motif([...])``), and drums raise. 1509 """ 1510 1511 target = to if isinstance(to, Degree) else Degree(int(to)) 1512 1513 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1514 1515 if not pitched_indices: 1516 return self 1517 1518 last = self.events[pitched_indices[-1]] 1519 1520 if not isinstance(last.pitch, Degree): 1521 raise TypeError( 1522 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1523 "content (build the call with motif([...]) for degree content)" 1524 ) 1525 1526 if isinstance(to, int): 1527 # Keep the call's register: only the step is re-aimed. 1528 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1529 1530 events = list(self.events) 1531 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1532 1533 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) 1534 1535 def pitched (self, spec: PitchSpec) -> "Motif": 1536 1537 """ 1538 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1539 1540 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1541 tones; any other string is a drum name; ints are MIDI; Degree / 1542 ChordTone / Approach pass through. 1543 """ 1544 1545 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1546 spec = ChordTone(spec) 1547 1548 # The new spec replaces whatever a capture resolved, so its origin goes too. 1549 events = tuple(dataclasses.replace(e, pitch=spec, origin=None) for e in self.events) 1550 1551 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1552 1553 def rhythm (self) -> "Motif": 1554 1555 """ 1556 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1557 1558 Timing, velocities, durations, and probabilities survive; re-pitch 1559 with :meth:`pitched` before placement (placing a skeleton raises). 1560 """ 1561 1562 events = tuple(dataclasses.replace(e, pitch=None, origin=None) for e in self.events) 1563 1564 return Motif(events=events, length=self.length) 1565 1566 def onsets (self) -> typing.List[float]: 1567 1568 """The note onset beats, in order — ready for rhythm-first generation.""" 1569 1570 return [e.beat for e in self.events] 1571 1572 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1573 1574 """ 1575 Transpose pitched content; the keyword names the unit. 1576 1577 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1578 raises on absolute-MIDI or drum content; ``semitones=`` is the 1579 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1580 on both — a transposed drum name is a different instrument, not a 1581 transposition — and a captured drum raises too, because its number 1582 still remembers which instrument it came from. 1583 """ 1584 1585 if (steps is None) == (semitones is None): 1586 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1587 1588 def move (pitch: PitchSpec, origin: typing.Optional[str]) -> PitchSpec: 1589 1590 _refuse_captured_drum(origin, "transpose()", "transposed") 1591 1592 if pitch is None: 1593 return None 1594 1595 if isinstance(pitch, Approach): 1596 moved = move(pitch.target, None) 1597 if not isinstance(moved, (int, Degree, ChordTone)): 1598 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1599 return Approach(moved) 1600 1601 if steps is not None: 1602 if isinstance(pitch, Degree): 1603 return dataclasses.replace(pitch, step=pitch.step + steps) 1604 raise TypeError( 1605 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1606 f"has no degrees (use semitones= for MIDI ints)" 1607 ) 1608 1609 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1610 1611 if isinstance(pitch, int): 1612 return pitch + semitones 1613 if isinstance(pitch, Degree): 1614 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1615 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1616 1617 events = tuple(dataclasses.replace(e, pitch=move(e.pitch, e.origin)) for e in self.events) 1618 1619 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1620 1621 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1622 1623 """ 1624 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1625 degree content around a degree pivot (default: the first note's pitch). 1626 Drum motifs raise, captured ones included. 1627 """ 1628 1629 pitched_events = [e for e in self.events if e.pitch is not None] 1630 1631 if not pitched_events: 1632 return self 1633 1634 first = pitched_events[0].pitch 1635 1636 if pivot is None: 1637 if isinstance(first, int): 1638 pivot = first 1639 elif isinstance(first, Degree): 1640 pivot = first.step 1641 else: 1642 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1643 1644 def mirror (pitch: PitchSpec, origin: typing.Optional[str]) -> PitchSpec: 1645 1646 _refuse_captured_drum(origin, "invert()", "mirrored") 1647 1648 if pitch is None: 1649 return None 1650 if isinstance(pitch, int): 1651 return 2 * pivot - pitch 1652 if isinstance(pitch, Degree): 1653 mirrored = 2 * pivot - pitch.step 1654 if mirrored < 1: 1655 raise ValueError( 1656 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1657 f"raise the pivot or use Degree octaves" 1658 ) 1659 # Reflection around the pivot (read at octave 0) is an isometry, so a 1660 # note's register flips too: a degree an octave above the pivot lands an 1661 # octave below it. Negating octave needs no scale length and leaves 1662 # octave-0 content unchanged. 1663 return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma) 1664 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1665 1666 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch, e.origin)) for e in self.events) 1667 1668 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1669 1670 # ── description ───────────────────────────────────────────────────── 1671 1672 def describe (self) -> str: 1673 1674 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1675 1676 notes = ", ".join(f"{_event_label(e)}@{e.beat:g}" for e in self.events) 1677 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1678 1679 if self.controls: 1680 gestures = ", ".join(_control_label(c) for c in self.controls) 1681 parts.append(f"controls [{gestures}]") 1682 1683 return " ".join(parts) 1684 1685 def __str__ (self) -> str: 1686 1687 """Printable form (same as :meth:`describe`).""" 1688 1689 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.
433 @classmethod 434 def empty (cls) -> "Motif": 435 436 """The empty motif (zero events, zero length) — the identity for ``then``.""" 437 438 return cls(events=(), length=0.0)
The empty motif (zero events, zero length) — the identity for then.
440 @classmethod 441 def from_events ( 442 cls, 443 events: typing.Iterable[MotifEvent], 444 length: typing.Optional[float] = None, 445 controls: typing.Iterable[ControlEvent] = (), 446 ) -> "Motif": 447 448 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 449 450 events = tuple(events) 451 controls = tuple(controls) 452 453 return cls( 454 events = events, 455 length = _computed_length(events, controls) if length is None else length, 456 controls = controls, 457 )
Build a motif from explicit events (power use; length defaults to the next whole beat).
499 @classmethod 500 def degrees ( 501 cls, 502 degrees: typing.List[typing.Union[int, Degree, None]], 503 beats: typing.Optional[typing.List[float]] = None, 504 velocities: typing.Any = _DEFAULT_VELOCITY, 505 durations: typing.Any = 1.0, 506 probabilities: typing.Any = 1.0, 507 length: typing.Optional[float] = None, 508 ) -> "Motif": 509 510 """ 511 A melody written as 1-based scale degrees, one per beat by default. 512 513 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 514 rest (the beat slot still advances), or :class:`Degree` for octave/ 515 chromatic detail. Resolved against key + scale at placement. 516 Durations default to a full beat (each note holds its slot). 517 """ 518 519 converted: typing.List[PitchSpec] = [] 520 521 for element in degrees: 522 if isinstance(element, int): 523 if element > _MAX_PLAUSIBLE_DEGREE: 524 raise ValueError( 525 f"Degree {element} is implausibly large — scale degrees are 1-based " 526 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 527 ) 528 converted.append(Degree(element)) 529 elif isinstance(element, Degree) or element is None: 530 converted.append(element) 531 else: 532 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 533 534 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).
536 @classmethod 537 def notes ( 538 cls, 539 notes: typing.List[typing.Union[int, None]], 540 beats: typing.Optional[typing.List[float]] = None, 541 velocities: typing.Any = _DEFAULT_VELOCITY, 542 durations: typing.Any = 1.0, 543 probabilities: typing.Any = 1.0, 544 length: typing.Optional[float] = None, 545 ) -> "Motif": 546 547 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 548 549 for element in notes: 550 # bool is a subclass of int, but True/False are never MIDI notes. 551 if isinstance(element, bool) or not (isinstance(element, int) or element is None): 552 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 553 554 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
A melody written as absolute MIDI note numbers (60 = middle C); None = rest.
556 @classmethod 557 def hits ( 558 cls, 559 pitch: typing.Union[int, str], 560 beats: typing.List[float], 561 length: typing.Optional[float] = None, 562 velocities: typing.Any = _DEFAULT_VELOCITY, 563 durations: typing.Any = 0.1, 564 probabilities: typing.Any = 1.0, 565 ) -> "Motif": 566 567 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 568 569 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.
571 @classmethod 572 def steps ( 573 cls, 574 steps: typing.List[int], 575 pitches: typing.Any, 576 velocities: typing.Any = _DEFAULT_VELOCITY, 577 durations: typing.Any = 0.1, 578 probabilities: typing.Any = 1.0, 579 step_duration: float = 0.25, 580 length: typing.Optional[float] = None, 581 ) -> "Motif": 582 583 """ 584 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 585 grid indices (sixteenths by default), ``pitches`` a scalar or 586 parallel list of MIDI ints or drum names. 587 """ 588 589 n = len(steps) 590 pitch_list = _expand("pitches", pitches, n) 591 onsets = [s * step_duration for s in steps] 592 593 if length is None and n: 594 length = float(math.ceil((max(steps) + 1) * step_duration)) 595 596 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.
598 @classmethod 599 def euclidean ( 600 cls, 601 pulses: int, 602 steps: int, 603 pitch: typing.Union[int, str], 604 length: float = 4.0, 605 velocities: typing.Any = _DEFAULT_VELOCITY, 606 durations: typing.Any = 0.1, 607 probabilities: typing.Any = 1.0, 608 ) -> "Motif": 609 610 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 611 612 # bool is a subclass of int, but True/False are never MIDI notes. 613 if isinstance(pitch, bool): 614 raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}") 615 616 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 617 # It validates pulses first, so pulses > steps still raises clearly. 618 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 619 620 if steps <= 0: 621 # A grid of zero steps holds no onsets — an empty motif of the given 622 # length, matching pulses=0 on a real grid (the empty-input policy). 623 return cls._from_sequence([], [], velocities, durations, probabilities, length) 624 625 step_duration = length / steps 626 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 627 628 return cls._from_sequence( 629 [pitch] * len(onsets), 630 onsets, 631 velocities, durations, probabilities, length, 632 )
A euclidean rhythm as a value: pulses spread evenly across steps over length beats.
634 @classmethod 635 def preset ( 636 cls, 637 name: str, 638 pitch: typing.Optional[typing.Union[int, str]] = None, 639 length: float = 4.0, 640 velocities: typing.Any = _DEFAULT_VELOCITY, 641 durations: typing.Any = 0.1, 642 probabilities: typing.Any = 1.0, 643 ) -> "Motif": 644 645 """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. 646 647 Looks a curated timeline up in the world-rhythm table (clave family, 648 West-African bell patterns, tresillo/cinquillo, samba) and lays its 649 onsets across *length* beats. Onset positions are exact pulse indices 650 from Toussaint's "The Geometry of Musical Rhythm"; each preset declares 651 its own grid (16 for the clave/4-4 timelines, 12 for the bell 652 patterns) and a default drum voice. 653 654 Parameters: 655 name: A preset name (``KeyError``-style ValueError lists them all). 656 pitch: The voice — a drum name or MIDI int; defaults to the 657 preset's General-MIDI voice (``"claves"``, ``"cowbell"``, 658 ``"side_stick"``, ``"low_conga"``), so it sounds against the 659 standard GM drum map without a ``pitch=``. 660 length: Total beats the cycle spans (4 = one common-time bar). 661 velocities / durations / probabilities: The parallel-list params. 662 663 Returns: 664 A drum/pitched :class:`Motif` of the timeline's onsets. 665 666 Raises: 667 ValueError: If *name* is not a known preset. 668 669 Example: 670 ```python 671 clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" 672 bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse 673 ``` 674 """ 675 676 if name not in _WORLD_RHYTHMS: 677 known = ", ".join(sorted(_WORLD_RHYTHMS)) 678 raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") 679 680 steps, grid, voice = _WORLD_RHYTHMS[name] 681 682 return cls.steps( 683 steps = list(steps), 684 pitches = pitch if pitch is not None else voice, 685 velocities = velocities, 686 durations = durations, 687 probabilities = probabilities, 688 step_duration = length / grid, 689 length = length, 690 )
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
763 @classmethod 764 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": 765 766 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 767 768 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.
770 @classmethod 771 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["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 772 773 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 774 775 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().
777 @classmethod 778 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 779 780 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 781 782 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().
784 @classmethod 785 def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 786 787 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 788 789 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().
791 @classmethod 792 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": 793 794 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 795 796 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().
798 @classmethod 799 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["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 800 801 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 802 803 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().
805 @classmethod 806 def rpn (cls, parameter: typing.Union[int, "subsequence.declarations.RpnParameter"], 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": 807 808 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 809 810 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().
812 @classmethod 813 def rpn_ramp (cls, parameter: typing.Union[int, "subsequence.declarations.RpnParameter"], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 814 815 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 816 817 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().
819 @classmethod 820 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 821 822 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 823 824 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
Discrete OSC float sends at beat positions — mirrors p.osc().
826 @classmethod 827 def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 828 829 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 830 831 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().
835 def then (self, other: "Motif") -> "Motif": 836 837 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 838 839 if not isinstance(other, Motif): 840 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 841 842 return Motif( 843 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 844 length = self.length + other.length, 845 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 846 # fit is a dial, not content: keep ours, inherit the other's when 847 # we have none — join()/tiling folds from empty() (fit=None), and 848 # must not silently strip a generated motif's chord-snapping. 849 fit = self.fit if self.fit is not None else other.fit, 850 )
Closed sequential concat: glue other after this motif into ONE longer motif.
852 @classmethod 853 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 854 855 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 856 857 result = cls.empty() 858 859 for m in motifs: 860 result = result.then(m) 861 862 return result
Fold a list of motifs into one with then (empty list → Motif.empty()).
864 @classmethod 865 def generate ( 866 cls, 867 rhythm: typing.Any, 868 length: typing.Optional[float] = None, 869 scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, 870 contour: typing.Optional[str] = None, 871 end_on: typing.Optional[typing.Union[int, Degree]] = None, 872 cadence: typing.Optional[str] = None, 873 pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, 874 max_pitches: typing.Optional[int] = None, 875 velocities: typing.Any = _DEFAULT_VELOCITY, 876 durations: typing.Any = 0.25, 877 seed: typing.Optional[int] = None, 878 rng: typing.Optional[random.Random] = None, 879 state: typing.Optional[typing.Any] = None, 880 nir_strength: float = 0.5, 881 pitch_diversity: float = 0.6, 882 tessitura_strength: float = 0.6, 883 ) -> "Motif": 884 885 """Generate a melodic motif — rhythm first, pitches walked, a value out. 886 887 The melody engine emitting a value: you give the **rhythm** (an onset 888 list in beats, or another motif whose rhythm to borrow — cross-pattern 889 rhythm reuse is shared values); the engine walks pitches over it 890 through the soft scoring factors (NIR expectation, contour envelope, 891 tessitura regression, diversity), honouring any pins. 892 893 The result emits **scale degrees** (resolved at placement against the 894 composition key/scale), so a generated hook transposes, varies, and 895 develops like a hand-written one. ``scale=`` constrains *candidate 896 choice only*: a name or interval list masks which pitches the walk 897 may use, spelled relative to its best-fit reference (major or minor) 898 — bind it in a composition whose scale matches that family and 899 resolution is exact. An explicit MIDI pitch pool (a list of note 900 numbers) switches to absolute output (the sieve/atonal path). 901 902 Parameters: 903 rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif 904 (its onsets are borrowed). 905 length: Motif length in beats; defaults to the onsets rounded 906 up to a whole 4-beat bar. 907 scale: A scale name, an interval list, or an explicit MIDI 908 pitch pool. ``None`` = the plain seven degrees. 909 contour: Envelope shaping the line's height over its span — 910 ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. 911 end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. 912 Degree semantics: raises with an explicit MIDI pool (pin the 913 exact note instead). 914 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 915 ``"fakeout"``) — the line closes on that cadence's melodic 916 degree (1 for the full closes and the fakeout, 5 for the 917 open half). Sugar for ``end_on=``; conflicts with it, and 918 raises with an explicit MIDI pool like ``end_on=``. 919 pins: ``{position: degree}`` — 1-based note positions (``-1`` = 920 the last, the Python idiom); the engine fills between. With 921 an explicit MIDI pool there are no degrees to read, so each 922 pin is the exact MIDI note to play (``Degree`` pins raise). 923 max_pitches: Cap on distinct pitches (a tight pool is a hook); 924 keeps the most central candidates. 925 velocities / durations: Scalar or per-note list (the parallel- 926 list convention). 927 seed: Seed for the walk (required or warned — module-level 928 nondeterminism breaks live reload). 929 rng: Explicit stream (overrides ``seed``). 930 state: A ``MelodicState`` whose dials, scoring factors, and 931 melodic history seed the walk. It is **copied** — building 932 a value never mutates a module-level live object. The 933 candidate pool is not carried over: it is always rebuilt 934 from ``scale=`` (pass an explicit pool there instead), 935 though the state's key still sets the tonic that the NIR 936 closure rule lands on. 937 nir_strength / pitch_diversity / tessitura_strength: The walk's 938 dials when no ``state`` is given. 939 940 Example: 941 ```python 942 hook = subsequence.Motif.generate( 943 rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", 944 contour="arch", end_on=1, seed=7, 945 ) 946 ``` 947 """ 948 949 import subsequence.melodic_state 950 951 onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm] 952 953 if cadence is not None: 954 if end_on is not None: 955 raise ValueError("cadence= already names the close degree — it conflicts with end_on=") 956 end_on = subsequence.cadences.cadence_formula(cadence).close_degree 957 958 if not onsets: 959 raise ValueError("generate() needs at least one onset — the rhythm comes first") 960 if sorted(onsets) != onsets: 961 raise ValueError("rhythm onsets must ascend") 962 963 if length is None: 964 length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) 965 if onsets[-1] >= length: 966 raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}") 967 968 if rng is None: 969 if seed is None: 970 warnings.warn( 971 "generate() without seed= is nondeterministic — pass seed= so the " 972 "value survives live reload", 973 stacklevel = 2, 974 ) 975 rng = random.Random() 976 else: 977 rng = random.Random(seed) 978 979 # --- The candidate pool ------------------------------------------------ 980 absolute_pool: typing.Optional[typing.List[int]] = None 981 intervals: typing.List[int] 982 983 if scale is None: 984 intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) 985 elif isinstance(scale, str): 986 intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) 987 else: 988 values = [int(v) for v in scale] 989 if values and (min(values) != 0 or max(values) > 11): 990 absolute_pool = sorted(values) # an explicit MIDI pool: absolute output 991 intervals = [] 992 else: 993 intervals = sorted(set(values)) 994 995 # Best-fit reference scale for degree spelling: whichever of major/ 996 # minor contains more of the pool (ties to major). Bound under a 997 # matching composition scale, resolution is exact. 998 if absolute_pool is None: 999 ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) 1000 aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) 1001 reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian" 1002 reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name)) 1003 1004 # --- The walking state (copied, never mutated in place) ---------------- 1005 if state is not None: 1006 walker = state.clone() 1007 walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a 1008 # note, so the walker never rests (and never falls 1009 # back to a stuck repeat) — rests come from the rhythm 1010 else: 1011 walker = subsequence.melodic_state.MelodicState( 1012 nir_strength = nir_strength, 1013 pitch_diversity = pitch_diversity, 1014 tessitura_strength = tessitura_strength, 1015 chord_weight = 0.0, # values have no chord context; fit applies at placement 1016 ) 1017 1018 if absolute_pool is not None: 1019 walker.set_pool(absolute_pool) 1020 else: 1021 # Offsets over ~1.5 octaves anchored at 60 — register is decided 1022 # at placement (root=), so the anchor is arbitrary and erased. 1023 walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19]) 1024 1025 if max_pitches is not None: 1026 if max_pitches < 1: 1027 raise ValueError("max_pitches must be at least 1") 1028 pool = sorted(walker._pitch_pool) 1029 centre = pool[len(pool) // 2] 1030 walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches])) 1031 1032 # --- Pins --------------------------------------------------------------- 1033 resolved_pins: typing.Dict[int, int] = {} 1034 combined = dict(pins or {}) 1035 1036 # cadence=/end_on= name scale DEGREES — meaningless against an explicit 1037 # MIDI pool, where they would silently land as raw (sub-audio) note 1038 # numbers. 1039 if absolute_pool is not None and end_on is not None: 1040 raise ValueError( 1041 "cadence=/end_on= name scale degrees, but this motif uses an " 1042 "explicit MIDI pool — pin the exact closing note instead: " 1043 "pins={-1: <midi note>}" 1044 ) 1045 1046 if end_on is not None: 1047 if -1 in combined or len(onsets) in combined: 1048 raise ValueError("end_on conflicts with a pin on the last note — they name the same position") 1049 combined[-1] = end_on 1050 1051 for pin_position, pin_spec in combined.items(): 1052 if not isinstance(pin_position, int) or isinstance(pin_position, bool): 1053 raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}") 1054 index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position 1055 if not 0 <= index < len(onsets): 1056 raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm") 1057 if absolute_pool is not None: 1058 # A raw int pins the exact MIDI note; a Degree has no meaning 1059 # here (the pool defines no scale to read it against). 1060 if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): 1061 raise ValueError( 1062 f"pin {pin_spec!r} is a scale degree, but this motif uses an " 1063 "explicit MIDI pool — pin the exact MIDI note instead " 1064 "(e.g. pins={-1: 52})" 1065 ) 1066 resolved_pins[index] = int(pin_spec) 1067 else: 1068 degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) 1069 step_index = (degree.step - 1) % len(reference) 1070 carry = (degree.step - 1) // len(reference) 1071 resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma 1072 1073 # --- The walk ----------------------------------------------------------- 1074 envelopes: typing.Dict[str, typing.Callable[[float], float]] = { 1075 "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), 1076 "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), 1077 "ascending": lambda pos: 0.1 + 0.85 * pos, 1078 "descending": lambda pos: 0.95 - 0.85 * pos, 1079 } 1080 1081 if contour is not None and contour not in envelopes: 1082 known = ", ".join(sorted(envelopes)) 1083 raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") 1084 1085 chosen_pitches: typing.List[int] = [] 1086 1087 for index, onset in enumerate(onsets): 1088 1089 if index in resolved_pins: 1090 pitch = resolved_pins[index] 1091 walker.record(pitch) # pins enter the NIR context like chosen notes 1092 else: 1093 span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 1094 target = envelopes[contour](span_position) if contour is not None else None 1095 picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target) 1096 pitch = picked if picked is not None else walker._pitch_pool[0] 1097 1098 chosen_pitches.append(pitch) 1099 1100 # --- Emission ------------------------------------------------------------ 1101 velocity_values = _expand("velocities", velocities, len(onsets)) 1102 duration_values = _expand("durations", durations, len(onsets)) 1103 1104 events = [] 1105 1106 for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): 1107 1108 spec: PitchSpec 1109 1110 if absolute_pool is not None: 1111 spec = pitch 1112 else: 1113 offset = pitch - 60 1114 octave, pc = divmod(offset, 12) 1115 if pc in reference: 1116 spec = Degree(reference.index(pc) + 1, octave = octave) 1117 elif (pc + 1) % 12 in reference and pc + 1 <= 11: 1118 spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1) 1119 else: 1120 spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1) 1121 1122 events.append(MotifEvent( 1123 beat = onset, 1124 pitch = spec, 1125 velocity = velocity_values[index], 1126 duration = float(duration_values[index]), 1127 )) 1128 1129 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, )
1131 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 1132 1133 """ 1134 Parallel merge (the spelled form of ``&``): event union, length = max. 1135 1136 No implicit tiling — a short gesture stacked under a long figure 1137 plays once. Phrase operands flatten first. 1138 """ 1139 1140 if isinstance(other, Phrase): 1141 merged = other.flatten() 1142 elif isinstance(other, Motif): 1143 merged = other 1144 else: 1145 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 1146 1147 return Motif( 1148 events = self.events + merged.events, 1149 length = max(self.length, merged.length), 1150 controls = self.controls + merged.controls, 1151 fit = self.fit, 1152 )
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.
1154 def slice (self, start: float, end: float) -> "Motif": 1155 1156 """ 1157 A window onto the motif, on its own authority: events starting outside 1158 are dropped; durations and ramp spans truncate at the cut (a truncated 1159 ramp ends at its interpolated cut value). Beats shift so the window 1160 starts at 0. 1161 """ 1162 1163 if end <= start: 1164 raise ValueError(f"slice end ({end}) must be after start ({start})") 1165 1166 events = tuple( 1167 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 1168 for e in self.events 1169 if start <= e.beat < end 1170 ) 1171 1172 controls = [] 1173 1174 for c in self.controls: 1175 if not (start <= c.beat < end): 1176 continue 1177 if c.end is not None and c.beat + c.span > end: 1178 kept = end - c.beat 1179 controls.append(dataclasses.replace( 1180 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 1181 )) 1182 else: 1183 controls.append(dataclasses.replace(c, beat=c.beat - start)) 1184 1185 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.
1224 def reverse (self) -> "Motif": 1225 1226 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 1227 1228 events = tuple( 1229 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 1230 for e in self.events 1231 ) 1232 controls = tuple( 1233 dataclasses.replace( 1234 c, 1235 beat = max(0.0, self.length - c.beat - c.span), 1236 start = c.start if c.end is None else c.end, 1237 end = c.end if c.end is None else c.start, 1238 ) 1239 for c in self.controls 1240 ) 1241 1242 return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
Mirror the figure in time; ramps swap direction (a rising sweep falls).
1244 def rotate (self, beats: float) -> "Motif": 1245 1246 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 1247 1248 if self.length == 0: 1249 return self 1250 1251 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 1252 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 1253 1254 return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
Shift every onset by beats, wrapping modulo the length (spans ride along).
1256 def stretch (self, factor: float) -> "Motif": 1257 1258 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 1259 1260 if factor <= 0: 1261 raise ValueError(f"Stretch factor must be positive — got {factor}") 1262 1263 events = tuple( 1264 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 1265 for e in self.events 1266 ) 1267 controls = tuple( 1268 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 1269 for c in self.controls 1270 ) 1271 1272 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.
1274 def quantize (self, grid: float) -> "Motif": 1275 1276 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). 1277 1278 An onset exactly midway between grid lines snaps LATER (round half 1279 up) — every midpoint moves the same way, the predictable behaviour 1280 for a musician. (Python's own ``round()`` is half-to-even, which 1281 made exact midpoints snap in alternating directions.) 1282 """ 1283 1284 if grid <= 0: 1285 raise ValueError(f"Quantize grid must be positive — got {grid}") 1286 1287 events = tuple( 1288 dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) 1289 for e in self.events 1290 ) 1291 1292 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.)
1294 def accent (self, beat: float, amount: int = 20) -> "Motif": 1295 1296 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 1297 1298 def boost (velocity: subsequence.declarations.VelocityValue) -> subsequence.declarations.VelocityValue: 1299 # Clamp both ends: a negative amount (a de-accent) must not store 1300 # a velocity below 1, which MIDI cannot play. 1301 if isinstance(velocity, (tuple, list)): 1302 return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount))) 1303 return max(1, min(127, velocity + amount)) 1304 1305 events = tuple( 1306 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 1307 for e in self.events 1308 ) 1309 1310 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).
1312 def with_velocity (self, velocity: subsequence.declarations.VelocityValue) -> "Motif": 1313 1314 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 1315 1316 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 1317 1318 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).
1351 def vary ( 1352 self, 1353 notes: int = 1, 1354 position: str = "end", 1355 seed: typing.Optional[int] = None, 1356 rng: typing.Optional[random.Random] = None, 1357 keep_contour: bool = False, 1358 ) -> "Motif": 1359 1360 """Replace a few pitches, preserving the rhythm — the smallest variation. 1361 1362 Rhythm, velocities, durations, rests, and control gestures are 1363 untouched; only the chosen notes' pitches move (by a small melodic 1364 nudge: scale steps for degrees, semitones for MIDI ints). 1365 1366 Parameters: 1367 notes: How many pitched notes to vary (clamped to what exists). 1368 position: Which notes — ``"end"`` (the tail, the default), 1369 ``"start"``, or ``"anywhere"`` (drawn from the stream). 1370 seed: Seed for the variation. A standalone vary without a seed 1371 warns — module-level nondeterminism breaks live reload. 1372 rng: An explicit random stream (overrides ``seed``; used by 1373 recipe machinery). 1374 keep_contour: When True, the variation preserves the line's 1375 CSEG — every varied note keeps its rank relations with 1376 every other note, so the melodic shape is identical (the 1377 motif-identity guard). Where no nudge can preserve the 1378 contour, that note stays unchanged — shape wins over 1379 motion. 1380 1381 Example: 1382 ```python 1383 answer = call.vary(notes=1, seed=4) # same figure, new tail note 1384 ``` 1385 """ 1386 1387 if notes < 0: 1388 raise ValueError(f"notes must be at least 0, got {notes}") 1389 if position not in ("end", "start", "anywhere"): 1390 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 1391 1392 if rng is None: 1393 if seed is None: 1394 warnings.warn( 1395 "vary() without seed= is nondeterministic — pass seed= so the " 1396 "value survives live reload", 1397 stacklevel = 2, 1398 ) 1399 rng = random.Random() 1400 else: 1401 rng = random.Random(seed) 1402 1403 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1404 count = min(notes, len(pitched_indices)) 1405 1406 if count == 0: 1407 return self 1408 1409 if position == "end": 1410 chosen = pitched_indices[-count:] 1411 elif position == "start": 1412 chosen = pitched_indices[:count] 1413 else: 1414 chosen = sorted(rng.sample(pitched_indices, count)) 1415 1416 events = list(self.events) 1417 1418 for index in chosen: 1419 if keep_contour: 1420 replacement = self._contour_safe_nudge(events, index, pitched_indices, rng) 1421 if replacement is not None: 1422 events[index] = dataclasses.replace(events[index], pitch = replacement) 1423 else: 1424 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng, events[index].origin)) 1425 1426 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
1498 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 1499 1500 """Call → response: re-aim the tail to a stable degree. 1501 1502 The classic consequent move — the figure repeats but its last pitched 1503 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1504 or a full ``Degree`` for register control). Everything else — 1505 rhythm, the other pitches, velocities, controls — is untouched. 1506 1507 Degree content only: absolute MIDI has no degrees to re-aim (build 1508 the call with ``motif([...])``), and drums raise. 1509 """ 1510 1511 target = to if isinstance(to, Degree) else Degree(int(to)) 1512 1513 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1514 1515 if not pitched_indices: 1516 return self 1517 1518 last = self.events[pitched_indices[-1]] 1519 1520 if not isinstance(last.pitch, Degree): 1521 raise TypeError( 1522 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1523 "content (build the call with motif([...]) for degree content)" 1524 ) 1525 1526 if isinstance(to, int): 1527 # Keep the call's register: only the step is re-aimed. 1528 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1529 1530 events = list(self.events) 1531 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1532 1533 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.
1535 def pitched (self, spec: PitchSpec) -> "Motif": 1536 1537 """ 1538 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1539 1540 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1541 tones; any other string is a drum name; ints are MIDI; Degree / 1542 ChordTone / Approach pass through. 1543 """ 1544 1545 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1546 spec = ChordTone(spec) 1547 1548 # The new spec replaces whatever a capture resolved, so its origin goes too. 1549 events = tuple(dataclasses.replace(e, pitch=spec, origin=None) for e in self.events) 1550 1551 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.
1553 def rhythm (self) -> "Motif": 1554 1555 """ 1556 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1557 1558 Timing, velocities, durations, and probabilities survive; re-pitch 1559 with :meth:`pitched` before placement (placing a skeleton raises). 1560 """ 1561 1562 events = tuple(dataclasses.replace(e, pitch=None, origin=None) for e in self.events) 1563 1564 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).
1566 def onsets (self) -> typing.List[float]: 1567 1568 """The note onset beats, in order — ready for rhythm-first generation.""" 1569 1570 return [e.beat for e in self.events]
The note onset beats, in order — ready for rhythm-first generation.
1572 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1573 1574 """ 1575 Transpose pitched content; the keyword names the unit. 1576 1577 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1578 raises on absolute-MIDI or drum content; ``semitones=`` is the 1579 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1580 on both — a transposed drum name is a different instrument, not a 1581 transposition — and a captured drum raises too, because its number 1582 still remembers which instrument it came from. 1583 """ 1584 1585 if (steps is None) == (semitones is None): 1586 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1587 1588 def move (pitch: PitchSpec, origin: typing.Optional[str]) -> PitchSpec: 1589 1590 _refuse_captured_drum(origin, "transpose()", "transposed") 1591 1592 if pitch is None: 1593 return None 1594 1595 if isinstance(pitch, Approach): 1596 moved = move(pitch.target, None) 1597 if not isinstance(moved, (int, Degree, ChordTone)): 1598 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1599 return Approach(moved) 1600 1601 if steps is not None: 1602 if isinstance(pitch, Degree): 1603 return dataclasses.replace(pitch, step=pitch.step + steps) 1604 raise TypeError( 1605 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1606 f"has no degrees (use semitones= for MIDI ints)" 1607 ) 1608 1609 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1610 1611 if isinstance(pitch, int): 1612 return pitch + semitones 1613 if isinstance(pitch, Degree): 1614 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1615 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1616 1617 events = tuple(dataclasses.replace(e, pitch=move(e.pitch, e.origin)) for e in self.events) 1618 1619 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 — and a captured drum raises too, because its number
still remembers which instrument it came from.
1621 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1622 1623 """ 1624 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1625 degree content around a degree pivot (default: the first note's pitch). 1626 Drum motifs raise, captured ones included. 1627 """ 1628 1629 pitched_events = [e for e in self.events if e.pitch is not None] 1630 1631 if not pitched_events: 1632 return self 1633 1634 first = pitched_events[0].pitch 1635 1636 if pivot is None: 1637 if isinstance(first, int): 1638 pivot = first 1639 elif isinstance(first, Degree): 1640 pivot = first.step 1641 else: 1642 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1643 1644 def mirror (pitch: PitchSpec, origin: typing.Optional[str]) -> PitchSpec: 1645 1646 _refuse_captured_drum(origin, "invert()", "mirrored") 1647 1648 if pitch is None: 1649 return None 1650 if isinstance(pitch, int): 1651 return 2 * pivot - pitch 1652 if isinstance(pitch, Degree): 1653 mirrored = 2 * pivot - pitch.step 1654 if mirrored < 1: 1655 raise ValueError( 1656 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1657 f"raise the pivot or use Degree octaves" 1658 ) 1659 # Reflection around the pivot (read at octave 0) is an isometry, so a 1660 # note's register flips too: a degree an octave above the pivot lands an 1661 # octave below it. Negating octave needs no scale length and leaves 1662 # octave-0 content unchanged. 1663 return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma) 1664 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1665 1666 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch, e.origin)) for e in self.events) 1667 1668 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, captured ones included.
1672 def describe (self) -> str: 1673 1674 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1675 1676 notes = ", ".join(f"{_event_label(e)}@{e.beat:g}" for e in self.events) 1677 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1678 1679 if self.controls: 1680 gestures = ", ".join(_control_label(c) for c in self.controls) 1681 parts.append(f"controls [{gestures}]") 1682 1683 return " ".join(parts)
A readable one-line summary: length, notes (pitch@beat), and control gestures.
1833@dataclasses.dataclass(frozen=True) 1834class Phrase: 1835 1836 """ 1837 A sequence of Motifs with segmentation preserved. 1838 1839 Segmentation is the unit of editing — it is what development and 1840 per-region regeneration operate on. ``flatten()`` erases it into one 1841 long Motif. Length is the sum of segment lengths. 1842 1843 A phrase made by :meth:`develop` carries its recipe, so 1844 :meth:`reroll` can regenerate a region; transforms and hand edits 1845 return recipe-less phrases (their notes no longer come from the 1846 recipe, so there is nothing honest to regenerate from). 1847 """ 1848 1849 segments: typing.Tuple[Motif, ...] 1850 recipe: typing.Optional[_PhraseRecipe] 1851 1852 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1853 1854 """Coerce any iterable of Motifs.""" 1855 1856 segments = tuple(segments) 1857 1858 for segment in segments: 1859 if not isinstance(segment, Motif): 1860 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1861 1862 object.__setattr__(self, "segments", segments) 1863 object.__setattr__(self, "recipe", recipe) 1864 1865 @property 1866 def length (self) -> float: 1867 1868 """Total length in beats (sum of segment lengths).""" 1869 1870 return sum(segment.length for segment in self.segments) 1871 1872 @classmethod 1873 def develop ( 1874 cls, 1875 motif: Motif, 1876 bars: int = 8, 1877 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1878 seed: typing.Optional[int] = None, 1879 beats_per_bar: float = 4.0, 1880 ) -> "Phrase": 1881 1882 """Grow a motif into a phrase by a plan — the phrase generator. 1883 1884 ``plan`` follows the standard form. The literal form is a **list of 1885 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1886 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1887 label is a generated contrast unit (the source's rhythm, freshly 1888 re-pitched), a repeated label is a restatement, and *bars* spreads 1889 evenly across the units. A bare string is a **recipe name** from 1890 the curated table — ``plan="call_response"`` (call, answer, call, 1891 varied answer) — reserved for plans whose semantics exceed a label 1892 skeleton. A letter string is not a plan: a sequence of labels is a 1893 sequence, so it is a list. 1894 1895 The result carries its recipe, so :meth:`reroll` can regenerate a 1896 region later. 1897 1898 Parameters: 1899 motif: The source unit (its length must be ``bars / len(units)`` 1900 bars — the plan's units tile the phrase exactly). 1901 bars: Phrase length in bars (must divide evenly by the unit 1902 count). 1903 plan: A list of unit labels, or a recipe name. 1904 seed: Seed for the generated units. Without one, develop() 1905 warns — module-level nondeterminism breaks live reload. 1906 beats_per_bar: Bar size in beats (the value is context-free; 1907 4 is the common-time default). 1908 1909 Example: 1910 ```python 1911 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1912 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1913 ``` 1914 """ 1915 1916 if plan is None: 1917 raise ValueError( 1918 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1919 'or a recipe name (plan="call_response")' 1920 ) 1921 1922 if seed is None: 1923 warnings.warn( 1924 "develop() without seed= is nondeterministic — pass seed= so the " 1925 "value survives live reload", 1926 stacklevel = 2, 1927 ) 1928 1929 # How many units the plan asks for — known before any unit is built, 1930 # so a short motif can tile up to the unit size first. 1931 if isinstance(plan, str): 1932 if plan not in _PHRASE_RECIPES: 1933 known = ", ".join(sorted(_PHRASE_RECIPES)) 1934 hint = "" 1935 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1936 spelled = ", ".join(repr(c) for c in plan) 1937 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1938 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1939 unit_count = _PHRASE_RECIPES[plan][0] 1940 else: 1941 labels = list(plan) 1942 if not labels or not all(isinstance(label, str) and label for label in labels): 1943 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1944 unit_count = len(labels) 1945 1946 source = _tile_source(motif, bars, unit_count, beats_per_bar) 1947 1948 # An unseeded call draws a fresh salt so repeated calls genuinely 1949 # differ, as the warning above promises — interpolating None gave the 1950 # FIXED seed "None:..." and silently returned the same phrase every 1951 # time. 1952 salt = seed if seed is not None else random.randrange(2 ** 32) 1953 1954 if isinstance(plan, str): 1955 units = _PHRASE_RECIPES[plan][1](source, salt) 1956 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1957 else: 1958 generated: typing.Dict[str, Motif] = {labels[0]: source} 1959 for label in labels: 1960 if label not in generated: 1961 generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}")) 1962 units = [generated[label] for label in labels] 1963 stored_plan = tuple(labels) 1964 1965 return cls(units, recipe = _PhraseRecipe( 1966 source = motif, 1967 plan = stored_plan, 1968 bars = bars, 1969 seed = seed, 1970 beats_per_bar = beats_per_bar, 1971 )) 1972 1973 def reroll ( 1974 self, 1975 bar: typing.Optional[int] = None, 1976 bars: typing.Optional[typing.Sequence[int]] = None, 1977 seed: typing.Optional[int] = None, 1978 ) -> "Phrase": 1979 1980 """Regenerate only the named bars — rhythm and boundary pitches kept. 1981 1982 Within each named bar, the first and last pitched notes stay (the 1983 boundary pins) and the interior pitches re-roll from a fresh per-bar 1984 stream salted by ``seed=`` (an unseeded call draws a fresh salt, so 1985 each call genuinely differs); onsets, durations, velocities, rests, 1986 drums, and control gestures are untouched. Segmentation and the 1987 recipe survive, so rerolls compose. 1988 1989 Only a phrase that carries a recipe can reroll — a hand-written or 1990 transformed phrase raises loudly (its notes no longer come from a 1991 generator, so regenerating them would invent music). 1992 1993 Parameters: 1994 bar: A single 1-based bar to reroll. 1995 bars: A list of 1-based bars (the paired plural spelling). 1996 seed: Seed for the new pitches (salted per bar). Without one, 1997 reroll() warns. 1998 1999 Example: 2000 ```python 2001 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 2002 ``` 2003 """ 2004 2005 if self.recipe is None: 2006 raise ValueError( 2007 "this phrase carries no recipe (it was written by hand, or transformed " 2008 "since generation) — reroll() regenerates from a recipe; edit segments " 2009 "with replace(), or rebuild with Phrase.develop()" 2010 ) 2011 2012 if (bar is None) == (bars is None): 2013 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 2014 2015 region = [bar] if bar is not None else list(bars or []) 2016 beats_per_bar = self.recipe.beats_per_bar 2017 total_bars = int(round(self.length / beats_per_bar)) 2018 2019 for number in region: 2020 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 2021 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 2022 2023 if seed is None: 2024 warnings.warn( 2025 "reroll() without seed= is nondeterministic — pass seed= so the " 2026 "value survives live reload", 2027 stacklevel = 2, 2028 ) 2029 2030 # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would 2031 # "re-roll" to the identical pitches every time. 2032 salt = seed if seed is not None else random.randrange(2 ** 32) 2033 2034 windows = [ 2035 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}")) 2036 for number in sorted(set(region)) 2037 ] 2038 2039 new_segments: typing.List[Motif] = [] 2040 offset = 0.0 2041 2042 for segment in self.segments: 2043 2044 events = list(segment.events) 2045 2046 for window_start, window_end, rng in windows: 2047 2048 inside = [ 2049 index for index, event in enumerate(events) 2050 if window_start <= offset + event.beat < window_end 2051 and event.pitch is not None and not isinstance(event.pitch, str) 2052 ] 2053 2054 # Boundary pins: the first and last pitched notes of the bar 2055 # stay; only the interior re-rolls. 2056 for index in inside[1:-1]: 2057 events[index] = dataclasses.replace( 2058 events[index], 2059 pitch = segment._nudged_pitch(events[index].pitch, rng, events[index].origin), 2060 ) 2061 2062 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 2063 offset += segment.length 2064 2065 return Phrase(new_segments, recipe = self.recipe) 2066 2067 def flatten (self) -> Motif: 2068 2069 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 2070 2071 return Motif.join(self.segments) 2072 2073 # ── algebra ───────────────────────────────────────────────────────── 2074 2075 def __add__ (self, other: typing.Any) -> "Phrase": 2076 2077 """Append a Motif segment, or concatenate another Phrase's segments.""" 2078 2079 if isinstance(other, Motif): 2080 return Phrase(self.segments + (other,)) 2081 if isinstance(other, Phrase): 2082 return Phrase(self.segments + other.segments) 2083 2084 return NotImplemented 2085 2086 def __radd__ (self, other: typing.Any) -> "Phrase": 2087 2088 """A Motif on the left prepends as a segment.""" 2089 2090 if isinstance(other, Motif): 2091 return Phrase((other,) + self.segments) 2092 2093 return NotImplemented 2094 2095 def __mul__ (self, count: int) -> "Phrase": 2096 2097 """Tile the segments *count* times.""" 2098 2099 if not isinstance(count, int): 2100 return NotImplemented 2101 if count < 0: 2102 raise ValueError(f"Repetition count must be non-negative — got {count}") 2103 2104 return Phrase(self.segments * count) 2105 2106 __rmul__ = __mul__ 2107 2108 def __and__ (self, other: typing.Any) -> Motif: 2109 2110 """Parallel merge is vertical: Phrase operands flatten to Motif first.""" 2111 2112 if isinstance(other, (Motif, Phrase)): 2113 return self.flatten().stack(other) 2114 2115 return NotImplemented 2116 2117 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 2118 2119 """The spelled form of ``&`` — flattens, then merges.""" 2120 2121 return self.flatten().stack(other) 2122 2123 def slice (self, start: float, end: float) -> "Phrase": 2124 2125 """A window; re-segments at the cut points (partial segments are sliced).""" 2126 2127 segments = [] 2128 offset = 0.0 2129 2130 for segment in self.segments: 2131 seg_start, seg_end = offset, offset + segment.length 2132 lo, hi = max(start, seg_start), min(end, seg_end) 2133 if lo < hi: 2134 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 2135 offset = seg_end 2136 2137 return Phrase(segments) 2138 2139 def replace (self, position: int, motif: Motif) -> "Phrase": 2140 2141 """Replace the segment at a 1-based position (musicians count from one).""" 2142 2143 if not 1 <= position <= len(self.segments): 2144 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 2145 2146 segments = list(self.segments) 2147 segments[position - 1] = motif 2148 2149 return Phrase(segments) 2150 2151 # ── transforms: lifted segment-wise, except time-reordering ───────── 2152 2153 def reverse (self) -> "Phrase": 2154 2155 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 2156 2157 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments))) 2158 2159 def rotate (self, beats: float) -> "Phrase": 2160 2161 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 2162 2163 flat = self.flatten().rotate(beats) 2164 segments = [] 2165 offset = 0.0 2166 2167 # Re-segment by onset (events keep their full durations — a note may 2168 # ring past its new segment, exactly as it does on the flat timeline). 2169 for segment in self.segments: 2170 lo, hi = offset, offset + segment.length 2171 segments.append(Motif( 2172 events = tuple( 2173 dataclasses.replace(e, beat=e.beat - lo) 2174 for e in flat.events if lo <= e.beat < hi 2175 ), 2176 length = segment.length, 2177 controls = tuple( 2178 dataclasses.replace(c, beat=c.beat - lo) 2179 for c in flat.controls if lo <= c.beat < hi 2180 ), 2181 )) 2182 offset = hi 2183 2184 return Phrase(segments) 2185 2186 def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase": 2187 2188 """Apply a Motif transform to every segment.""" 2189 2190 return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments)) 2191 2192 def stretch (self, factor: float) -> "Phrase": 2193 2194 """Scale time in every segment (lengths scale with them).""" 2195 2196 return self._lift("stretch", factor) 2197 2198 def quantize (self, grid: float) -> "Phrase": 2199 2200 """Snap note onsets segment-wise.""" 2201 2202 return self._lift("quantize", grid) 2203 2204 def with_velocity (self, velocity: subsequence.declarations.VelocityValue) -> "Phrase": 2205 2206 """Replace every note's velocity, segment-wise.""" 2207 2208 return self._lift("with_velocity", velocity) 2209 2210 def pitched (self, spec: PitchSpec) -> "Phrase": 2211 2212 """Replace every pitch, segment-wise.""" 2213 2214 return self._lift("pitched", spec) 2215 2216 def rhythm (self) -> "Phrase": 2217 2218 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 2219 2220 return self._lift("rhythm") 2221 2222 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 2223 2224 """Transpose every segment (see :meth:`Motif.transpose`).""" 2225 2226 return self._lift("transpose", steps=steps, semitones=semitones) 2227 2228 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 2229 2230 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 2231 2232 if pivot is None: 2233 for segment in self.segments: 2234 for event in segment.events: 2235 if event.pitch is not None: 2236 if isinstance(event.pitch, int): 2237 pivot = event.pitch 2238 elif isinstance(event.pitch, Degree): 2239 pivot = event.pitch.step 2240 break 2241 if pivot is not None: 2242 break 2243 2244 return self._lift("invert", pivot=pivot) 2245 2246 def describe (self) -> str: 2247 2248 """A readable summary: total length and each segment on its own line.""" 2249 2250 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 2251 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 2252 2253 return "\n".join([header] + lines) 2254 2255 def __str__ (self) -> str: 2256 2257 """Printable form (same as :meth:`describe`).""" 2258 2259 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).
1852 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1853 1854 """Coerce any iterable of Motifs.""" 1855 1856 segments = tuple(segments) 1857 1858 for segment in segments: 1859 if not isinstance(segment, Motif): 1860 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1861 1862 object.__setattr__(self, "segments", segments) 1863 object.__setattr__(self, "recipe", recipe)
Coerce any iterable of Motifs.
1865 @property 1866 def length (self) -> float: 1867 1868 """Total length in beats (sum of segment lengths).""" 1869 1870 return sum(segment.length for segment in self.segments)
Total length in beats (sum of segment lengths).
1872 @classmethod 1873 def develop ( 1874 cls, 1875 motif: Motif, 1876 bars: int = 8, 1877 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1878 seed: typing.Optional[int] = None, 1879 beats_per_bar: float = 4.0, 1880 ) -> "Phrase": 1881 1882 """Grow a motif into a phrase by a plan — the phrase generator. 1883 1884 ``plan`` follows the standard form. The literal form is a **list of 1885 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1886 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1887 label is a generated contrast unit (the source's rhythm, freshly 1888 re-pitched), a repeated label is a restatement, and *bars* spreads 1889 evenly across the units. A bare string is a **recipe name** from 1890 the curated table — ``plan="call_response"`` (call, answer, call, 1891 varied answer) — reserved for plans whose semantics exceed a label 1892 skeleton. A letter string is not a plan: a sequence of labels is a 1893 sequence, so it is a list. 1894 1895 The result carries its recipe, so :meth:`reroll` can regenerate a 1896 region later. 1897 1898 Parameters: 1899 motif: The source unit (its length must be ``bars / len(units)`` 1900 bars — the plan's units tile the phrase exactly). 1901 bars: Phrase length in bars (must divide evenly by the unit 1902 count). 1903 plan: A list of unit labels, or a recipe name. 1904 seed: Seed for the generated units. Without one, develop() 1905 warns — module-level nondeterminism breaks live reload. 1906 beats_per_bar: Bar size in beats (the value is context-free; 1907 4 is the common-time default). 1908 1909 Example: 1910 ```python 1911 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1912 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1913 ``` 1914 """ 1915 1916 if plan is None: 1917 raise ValueError( 1918 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1919 'or a recipe name (plan="call_response")' 1920 ) 1921 1922 if seed is None: 1923 warnings.warn( 1924 "develop() without seed= is nondeterministic — pass seed= so the " 1925 "value survives live reload", 1926 stacklevel = 2, 1927 ) 1928 1929 # How many units the plan asks for — known before any unit is built, 1930 # so a short motif can tile up to the unit size first. 1931 if isinstance(plan, str): 1932 if plan not in _PHRASE_RECIPES: 1933 known = ", ".join(sorted(_PHRASE_RECIPES)) 1934 hint = "" 1935 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1936 spelled = ", ".join(repr(c) for c in plan) 1937 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1938 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1939 unit_count = _PHRASE_RECIPES[plan][0] 1940 else: 1941 labels = list(plan) 1942 if not labels or not all(isinstance(label, str) and label for label in labels): 1943 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1944 unit_count = len(labels) 1945 1946 source = _tile_source(motif, bars, unit_count, beats_per_bar) 1947 1948 # An unseeded call draws a fresh salt so repeated calls genuinely 1949 # differ, as the warning above promises — interpolating None gave the 1950 # FIXED seed "None:..." and silently returned the same phrase every 1951 # time. 1952 salt = seed if seed is not None else random.randrange(2 ** 32) 1953 1954 if isinstance(plan, str): 1955 units = _PHRASE_RECIPES[plan][1](source, salt) 1956 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1957 else: 1958 generated: typing.Dict[str, Motif] = {labels[0]: source} 1959 for label in labels: 1960 if label not in generated: 1961 generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}")) 1962 units = [generated[label] for label in labels] 1963 stored_plan = tuple(labels) 1964 1965 return cls(units, recipe = _PhraseRecipe( 1966 source = motif, 1967 plan = stored_plan, 1968 bars = bars, 1969 seed = seed, 1970 beats_per_bar = beats_per_bar, 1971 ))
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)
1973 def reroll ( 1974 self, 1975 bar: typing.Optional[int] = None, 1976 bars: typing.Optional[typing.Sequence[int]] = None, 1977 seed: typing.Optional[int] = None, 1978 ) -> "Phrase": 1979 1980 """Regenerate only the named bars — rhythm and boundary pitches kept. 1981 1982 Within each named bar, the first and last pitched notes stay (the 1983 boundary pins) and the interior pitches re-roll from a fresh per-bar 1984 stream salted by ``seed=`` (an unseeded call draws a fresh salt, so 1985 each call genuinely differs); onsets, durations, velocities, rests, 1986 drums, and control gestures are untouched. Segmentation and the 1987 recipe survive, so rerolls compose. 1988 1989 Only a phrase that carries a recipe can reroll — a hand-written or 1990 transformed phrase raises loudly (its notes no longer come from a 1991 generator, so regenerating them would invent music). 1992 1993 Parameters: 1994 bar: A single 1-based bar to reroll. 1995 bars: A list of 1-based bars (the paired plural spelling). 1996 seed: Seed for the new pitches (salted per bar). Without one, 1997 reroll() warns. 1998 1999 Example: 2000 ```python 2001 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 2002 ``` 2003 """ 2004 2005 if self.recipe is None: 2006 raise ValueError( 2007 "this phrase carries no recipe (it was written by hand, or transformed " 2008 "since generation) — reroll() regenerates from a recipe; edit segments " 2009 "with replace(), or rebuild with Phrase.develop()" 2010 ) 2011 2012 if (bar is None) == (bars is None): 2013 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 2014 2015 region = [bar] if bar is not None else list(bars or []) 2016 beats_per_bar = self.recipe.beats_per_bar 2017 total_bars = int(round(self.length / beats_per_bar)) 2018 2019 for number in region: 2020 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 2021 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 2022 2023 if seed is None: 2024 warnings.warn( 2025 "reroll() without seed= is nondeterministic — pass seed= so the " 2026 "value survives live reload", 2027 stacklevel = 2, 2028 ) 2029 2030 # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would 2031 # "re-roll" to the identical pitches every time. 2032 salt = seed if seed is not None else random.randrange(2 ** 32) 2033 2034 windows = [ 2035 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}")) 2036 for number in sorted(set(region)) 2037 ] 2038 2039 new_segments: typing.List[Motif] = [] 2040 offset = 0.0 2041 2042 for segment in self.segments: 2043 2044 events = list(segment.events) 2045 2046 for window_start, window_end, rng in windows: 2047 2048 inside = [ 2049 index for index, event in enumerate(events) 2050 if window_start <= offset + event.beat < window_end 2051 and event.pitch is not None and not isinstance(event.pitch, str) 2052 ] 2053 2054 # Boundary pins: the first and last pitched notes of the bar 2055 # stay; only the interior re-rolls. 2056 for index in inside[1:-1]: 2057 events[index] = dataclasses.replace( 2058 events[index], 2059 pitch = segment._nudged_pitch(events[index].pitch, rng, events[index].origin), 2060 ) 2061 2062 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 2063 offset += segment.length 2064 2065 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
2067 def flatten (self) -> Motif: 2068 2069 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 2070 2071 return Motif.join(self.segments)
Erase segmentation: one long Motif (the monoid homomorphism onto then).
2117 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 2118 2119 """The spelled form of ``&`` — flattens, then merges.""" 2120 2121 return self.flatten().stack(other)
The spelled form of & — flattens, then merges.
2123 def slice (self, start: float, end: float) -> "Phrase": 2124 2125 """A window; re-segments at the cut points (partial segments are sliced).""" 2126 2127 segments = [] 2128 offset = 0.0 2129 2130 for segment in self.segments: 2131 seg_start, seg_end = offset, offset + segment.length 2132 lo, hi = max(start, seg_start), min(end, seg_end) 2133 if lo < hi: 2134 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 2135 offset = seg_end 2136 2137 return Phrase(segments)
A window; re-segments at the cut points (partial segments are sliced).
2139 def replace (self, position: int, motif: Motif) -> "Phrase": 2140 2141 """Replace the segment at a 1-based position (musicians count from one).""" 2142 2143 if not 1 <= position <= len(self.segments): 2144 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 2145 2146 segments = list(self.segments) 2147 segments[position - 1] = motif 2148 2149 return Phrase(segments)
Replace the segment at a 1-based position (musicians count from one).
2153 def reverse (self) -> "Phrase": 2154 2155 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 2156 2157 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
Reverse the whole timeline: segments reverse order AND each reverses internally.
2159 def rotate (self, beats: float) -> "Phrase": 2160 2161 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 2162 2163 flat = self.flatten().rotate(beats) 2164 segments = [] 2165 offset = 0.0 2166 2167 # Re-segment by onset (events keep their full durations — a note may 2168 # ring past its new segment, exactly as it does on the flat timeline). 2169 for segment in self.segments: 2170 lo, hi = offset, offset + segment.length 2171 segments.append(Motif( 2172 events = tuple( 2173 dataclasses.replace(e, beat=e.beat - lo) 2174 for e in flat.events if lo <= e.beat < hi 2175 ), 2176 length = segment.length, 2177 controls = tuple( 2178 dataclasses.replace(c, beat=c.beat - lo) 2179 for c in flat.controls if lo <= c.beat < hi 2180 ), 2181 )) 2182 offset = hi 2183 2184 return Phrase(segments)
Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.
2192 def stretch (self, factor: float) -> "Phrase": 2193 2194 """Scale time in every segment (lengths scale with them).""" 2195 2196 return self._lift("stretch", factor)
Scale time in every segment (lengths scale with them).
2198 def quantize (self, grid: float) -> "Phrase": 2199 2200 """Snap note onsets segment-wise.""" 2201 2202 return self._lift("quantize", grid)
Snap note onsets segment-wise.
2204 def with_velocity (self, velocity: subsequence.declarations.VelocityValue) -> "Phrase": 2205 2206 """Replace every note's velocity, segment-wise.""" 2207 2208 return self._lift("with_velocity", velocity)
Replace every note's velocity, segment-wise.
2210 def pitched (self, spec: PitchSpec) -> "Phrase": 2211 2212 """Replace every pitch, segment-wise.""" 2213 2214 return self._lift("pitched", spec)
Replace every pitch, segment-wise.
2216 def rhythm (self) -> "Phrase": 2217 2218 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 2219 2220 return self._lift("rhythm")
Strip pitches segment-wise: a phrase-shaped skeleton.
2222 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 2223 2224 """Transpose every segment (see :meth:`Motif.transpose`).""" 2225 2226 return self._lift("transpose", steps=steps, semitones=semitones)
Transpose every segment (see Motif.transpose()).
2228 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 2229 2230 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 2231 2232 if pivot is None: 2233 for segment in self.segments: 2234 for event in segment.events: 2235 if event.pitch is not None: 2236 if isinstance(event.pitch, int): 2237 pivot = event.pitch 2238 elif isinstance(event.pitch, Degree): 2239 pivot = event.pitch.step 2240 break 2241 if pivot is not None: 2242 break 2243 2244 return self._lift("invert", pivot=pivot)
Mirror pitches in every segment around one pivot (see Motif.invert()).
2246 def describe (self) -> str: 2247 2248 """A readable summary: total length and each segment on its own line.""" 2249 2250 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 2251 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 2252 2253 return "\n".join([header] + lines)
A readable summary: total length and each segment on its own line.
2262def motif ( 2263 degrees: typing.List[typing.Union[int, Degree, None]], 2264 beats: typing.Optional[typing.List[float]] = None, 2265 velocities: typing.Any = _DEFAULT_VELOCITY, 2266 durations: typing.Any = 1.0, 2267 probabilities: typing.Any = 1.0, 2268 length: typing.Optional[float] = None, 2269) -> Motif: 2270 2271 """ 2272 The lowercase shortcut: a melody as 1-based scale degrees. 2273 2274 ``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` — 2275 relative pitch is the primary form. For absolute MIDI note numbers use 2276 ``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so 2277 a pasted MIDI list fails loud instead of squealing octaves up. 2278 """ 2279 2280 return Motif.degrees( 2281 degrees, 2282 beats = beats, 2283 velocities = velocities, 2284 durations = durations, 2285 probabilities = probabilities, 2286 length = length, 2287 )
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.
2290def sentence ( 2291 motif: Motif, 2292 bars: int = 8, 2293 cadence: str = "strong", 2294 seed: typing.Optional[int] = None, 2295 beats_per_bar: float = 4.0, 2296) -> Phrase: 2297 2298 """The classical sentence, as a thin combinator — idea, idea, drive, close. 2299 2300 Four units: the basic idea stated twice (the presentation), a generated 2301 contrast unit (the continuation — the source's rhythm, freshly 2302 re-pitched), and a second contrast unit whose tail lands on the 2303 cadence's close degree (the cadential close). An 8-bar sentence from a 2304 2-bar idea is the textbook proportion; a shorter idea tiles up to the 2305 unit size first. 2306 2307 The melodic side of a cadence only — pair it with the harmonic side 2308 (``prog.cadence()``, ``Progression.generate(cadence=)``, or 2309 ``request_cadence()``) and the two arrive together. 2310 2311 Parameters: 2312 motif: The basic idea (degree content — the close re-aims a degree). 2313 bars: Sentence length (must divide evenly across the 4 units). 2314 cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5, 2315 ``"soft"``/``"fakeout"`` on 1 (theory aliases accepted). 2316 seed: Seed for the generated continuation units (seed-or-warn). 2317 beats_per_bar: Bar size in beats (context-free; 4 is the default). 2318 2319 Example: 2320 ```python 2321 idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 2322 verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11) 2323 ``` 2324 """ 2325 2326 spec = subsequence.cadences.cadence_formula(cadence) 2327 2328 if seed is None: 2329 warnings.warn( 2330 "sentence() without seed= is nondeterministic — pass seed= so the " 2331 "value survives live reload", 2332 stacklevel = 2, 2333 ) 2334 2335 source = _tile_source(motif, bars, 4, beats_per_bar) 2336 2337 # Unseeded calls draw a fresh salt (a fixed "None:..." seed would return 2338 # the same sentence every time, belying the warning above). 2339 salt = seed if seed is not None else random.randrange(2 ** 32) 2340 2341 continuation = _contrast_unit(source, random.Random(f"{salt}:sentence:continuation")) 2342 cadential = _contrast_unit(source, random.Random(f"{salt}:sentence:cadential")).answer(to = spec.close_degree) 2343 2344 return Phrase([source, source, continuation, cadential], recipe = _PhraseRecipe( 2345 source = motif, 2346 plan = "sentence", 2347 bars = bars, 2348 seed = seed, 2349 beats_per_bar = beats_per_bar, 2350 cadence = spec.name, 2351 ))
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)
2354def period ( 2355 antecedent: typing.Union[Motif, Phrase], 2356 cadence: str = "strong", 2357 beats_per_bar: float = 4.0, 2358) -> Phrase: 2359 2360 """The classical period, as a thin combinator — question, then answer. 2361 2362 Two halves: the antecedent with its tail re-aimed to the open half-close 2363 (degree 5 — the question), then the same material restated with its tail 2364 on the cadence's close degree (the answer). The two halves differ 2365 exactly at their closes — the open/closed contrast *is* the period. 2366 2367 Deterministic: no notes are generated, only the two tail notes re-aim 2368 (so there is no seed). Vary the consequent yourself for a looser 2369 restatement: ``period(a).reroll(bar=7, seed=4)``. 2370 2371 Parameters: 2372 antecedent: The first half — a Motif, or a Phrase whose segmentation 2373 is kept (only its last segment's tail re-aims). 2374 cadence: The consequent's close — ``"strong"`` lands on 1 (theory 2375 aliases accepted). 2376 beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows. 2377 2378 Example: 2379 ```python 2380 idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8) 2381 lead = subsequence.period(idea) # 16 beats: half-close, then home 2382 ``` 2383 """ 2384 2385 spec = subsequence.cadences.cadence_formula(cadence) 2386 open_degree = subsequence.cadences.cadence_formula("open").close_degree 2387 2388 units = list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent] 2389 2390 if not units or sum(unit.length for unit in units) <= 0: 2391 raise ValueError("cannot build a period from an empty antecedent") 2392 2393 tail = units[-1] 2394 2395 antecedent_units = units[:-1] + [tail.answer(to = open_degree)] 2396 consequent_units = units[:-1] + [tail.answer(to = spec.close_degree)] 2397 2398 source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent 2399 total_beats = 2 * sum(unit.length for unit in units) 2400 2401 return Phrase(antecedent_units + consequent_units, recipe = _PhraseRecipe( 2402 source = source, 2403 plan = "period", 2404 bars = int(round(total_beats / beats_per_bar)), 2405 seed = None, 2406 beats_per_bar = beats_per_bar, 2407 cadence = spec.name, 2408 ))
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.
97@dataclasses.dataclass(frozen=True) 98class Degree: 99 100 """ 101 A scale degree — 1-based, resolved against key + scale at placement. 102 103 Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the 104 scale length and resolve into higher octaves). ``octave`` shifts whole 105 octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened). 106 """ 107 108 step: int 109 octave: int = 0 110 chroma: int = 0 111 112 def __post_init__ (self) -> None: 113 114 """Validate that the degree is 1-based and plausibly a degree.""" 115 116 if self.step < 1: 117 raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")
120@dataclasses.dataclass(frozen=True) 121class ChordTone: 122 123 """ 124 An index into the current chord's tones — 1-based, resolved at placement. 125 126 Accepts an int (1 = root, 2 = third, ...) or one of the names 127 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave`` 128 shifts whole octaves. 129 """ 130 131 index: int 132 octave: int = 0 133 134 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 135 136 """Normalize a tone name to its 1-based index.""" 137 138 if isinstance(index_or_name, str): 139 if index_or_name not in _CHORD_TONE_NAMES: 140 raise ValueError( 141 f"Unknown chord tone name '{index_or_name}' — " 142 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 143 ) 144 index = _CHORD_TONE_NAMES[index_or_name] 145 else: 146 index = index_or_name 147 148 if index < 1: 149 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 150 151 object.__setattr__(self, "index", index) 152 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.
134 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 135 136 """Normalize a tone name to its 1-based index.""" 137 138 if isinstance(index_or_name, str): 139 if index_or_name not in _CHORD_TONE_NAMES: 140 raise ValueError( 141 f"Unknown chord tone name '{index_or_name}' — " 142 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 143 ) 144 index = _CHORD_TONE_NAMES[index_or_name] 145 else: 146 index = index_or_name 147 148 if index < 1: 149 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 150 151 object.__setattr__(self, "index", index) 152 object.__setattr__(self, "octave", octave)
Normalize a tone name to its 1-based index.
155@dataclasses.dataclass(frozen=True) 156class Approach: 157 158 """ 159 A half-step approach into a target pitch at the next chord boundary. 160 161 Resolves at placement, one semitone below its target (the leading-tone 162 approach); a ``ChordTone`` target reads the NEXT chord through the 163 harmony window, so the approach lands as the harmony arrives. 164 """ 165 166 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.
288@dataclasses.dataclass(frozen=True) 289class MotifEvent: 290 291 """ 292 One timed note event inside a Motif. 293 294 ``pitch`` is a specification: an absolute MIDI int, a drum name string, 295 a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None 296 for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which 297 must be re-pitched via :meth:`Motif.pitched` before placement. 298 ``velocity`` is an int or a ``(low, high)`` random-range tuple. 299 300 ``origin`` names the drum a pitch was resolved *from*. Only 301 :meth:`~subsequence.pattern_builder.PatternBuilder.capture` sets it — 302 capture reads notes back as absolute MIDI, so ``"kick"`` arrives here as 303 ``36`` — and it is what lets the pitch-moving methods go on refusing a 304 drum they can no longer see. 305 """ 306 307 beat: float 308 pitch: PitchSpec 309 velocity: subsequence.declarations.VelocityValue = _DEFAULT_VELOCITY 310 duration: float = 0.25 311 probability: float = 1.0 312 origin: typing.Optional[str] = None # Drum name a captured pitch was resolved from; None for anything authored directly. 313 314 def __post_init__ (self) -> None: 315 316 """Validate ranges that are wrong at any placement.""" 317 318 if self.duration <= 0: 319 raise ValueError(f"Event duration must be positive — got {self.duration}") 320 if not 0.0 <= self.probability <= 1.0: 321 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 322 323 def _sort_key (self) -> tuple: 324 325 """Canonical ordering key — makes parallel merge order-independent.""" 326 327 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.
origin names the drum a pitch was resolved from. Only
~subsequence.pattern_builder.PatternBuilder.capture() sets it —
capture reads notes back as absolute MIDI, so "kick" arrives here as
36 — and it is what lets the pitch-moving methods go on refusing a
drum they can no longer see.
330@dataclasses.dataclass(frozen=True) 331class ControlEvent: 332 333 """ 334 One timed control gesture inside a Motif: a discrete write or a shaped ramp. 335 336 A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates 337 ``start`` → ``end`` over ``span`` beats through the easing ``shape``. 338 Pulse density (``resolution=``) is deliberately not stored here — beats 339 and shapes are music; MIDI traffic density is set at the placement call. 340 """ 341 342 beat: float 343 signal: ControlSignal 344 start: float 345 end: typing.Optional[float] = None 346 span: float = 0.0 347 shape: typing.Union["subsequence.declarations.EasingCurve", "subsequence.easing.EasingFn"] = "linear" 348 probability: float = 1.0 349 350 def __post_init__ (self) -> None: 351 352 """Validate the discrete/ramp invariants.""" 353 354 if (self.end is None) != (self.span == 0.0): 355 raise ValueError("A ramp needs both end= and span= (a discrete write has neither)") 356 if self.span < 0: 357 raise ValueError(f"Ramp span must be non-negative — got {self.span}") 358 if not 0.0 <= self.probability <= 1.0: 359 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 360 361 def _sort_key (self) -> tuple: 362 363 """Canonical ordering key — makes parallel merge order-independent.""" 364 365 end = self.start if self.end is None else self.end 366 return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability) 367 368 def _value_at (self, fraction: float) -> float: 369 370 """The interpolated value at a 0–1 fraction through the ramp.""" 371 372 if self.end is None: 373 return self.start 374 375 easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape) 376 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.
1094@dataclasses.dataclass(frozen=True) 1095class Progression: 1096 1097 """A frozen sequence of :class:`ChordSpan` — the governing harmony value. 1098 1099 Always a realised value: binding it to the clock freezes one realisation; 1100 ``p.progression()`` keeps its breathing behaviour by re-realising a fresh 1101 one each rebuild. Iterating yields ``(chord, start, length)`` 1102 :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so 1103 placement loops keep working unchanged. 1104 1105 The governing family supports ``+`` (concatenate) and ``*`` (tile) but 1106 never ``&`` — there is one current chord (P1, the type law). 1107 1108 Attributes: 1109 spans: The chord spans, in order. 1110 trailing_history: Engine continuity metadata set by 1111 :meth:`Composition.freeze` — the NIR history at capture time, 1112 restored on each frozen replay. Empty for hand-built values. 1113 """ 1114 1115 spans: typing.Tuple[ChordSpan, ...] 1116 trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = () 1117 1118 def __post_init__ (self) -> None: 1119 1120 """Normalise span containers to tuples.""" 1121 1122 object.__setattr__(self, "spans", tuple(self.spans)) 1123 object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) 1124 1125 if not self.spans: 1126 raise ValueError("a Progression needs at least one chord span") 1127 1128 # -- queries ------------------------------------------------------------ 1129 1130 @property 1131 def length (self) -> float: 1132 1133 """Total length in beats (the sum of span lengths).""" 1134 1135 return float(sum(span.beats for span in self.spans)) 1136 1137 @property 1138 def is_concrete (self) -> bool: 1139 1140 """True when every span is key-independent (no romans/degrees).""" 1141 1142 return all(span.is_concrete for span in self.spans) 1143 1144 @property 1145 def chords (self) -> typing.Tuple[typing.Any, ...]: 1146 1147 """The bare chords, one per span (concrete progressions only).""" 1148 1149 self._require_concrete("read .chords") 1150 1151 return tuple(span.chord for span in self.spans) 1152 1153 @property 1154 def loops_on_exhaustion (self) -> bool: 1155 1156 """True when the clock must loop rather than fall through to live stepping.""" 1157 1158 return any(isinstance(span.chord, PitchSet) for span in self.spans) 1159 1160 def _require_concrete (self, action: str) -> None: 1161 1162 """Raise with a resolution hint when key-relative spans remain.""" 1163 1164 if not self.is_concrete: 1165 relative = ", ".join(span.label() for span in self.spans if not span.is_concrete) 1166 raise ValueError( 1167 f"cannot {action} on a key-relative progression (contains {relative}) — " 1168 "call .resolve(key=...) first, or bind it where a key is known" 1169 ) 1170 1171 def __iter__ (self) -> typing.Iterator[ChordEvent]: 1172 1173 """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" 1174 1175 self._require_concrete("iterate") 1176 1177 cursor = 0.0 1178 1179 for span in self.spans: 1180 chord = DecoratedChord(span) if span.is_decorated else span.chord 1181 yield ChordEvent(chord=chord, start=cursor, length=span.beats) 1182 cursor += span.beats 1183 1184 def __len__ (self) -> int: 1185 1186 """The number of chord spans.""" 1187 1188 return len(self.spans) 1189 1190 def events (self) -> typing.Tuple[ChordEvent, ...]: 1191 1192 """The realised timeline as a tuple (iteration, materialised).""" 1193 1194 return tuple(self) 1195 1196 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 1197 1198 """Return ``(span, start, end)`` for the span sounding at *beat*. 1199 1200 *beat* wraps modulo the progression length, so the lookup also 1201 serves looped playback. 1202 """ 1203 1204 position = beat % self.length 1205 cursor = 0.0 1206 1207 for span in self.spans: 1208 if cursor <= position < cursor + span.beats: 1209 return span, cursor, cursor + span.beats 1210 cursor += span.beats 1211 1212 final = self.spans[-1] 1213 return final, self.length - final.beats, self.length 1214 1215 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 1216 1217 """Resolve every key-relative span against a key (name or pitch class).""" 1218 1219 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 1220 1221 return dataclasses.replace( 1222 self, 1223 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1224 ) 1225 1226 @classmethod 1227 def generate ( 1228 cls, 1229 style: typing.Union[str, typing.Any] = "functional_major", 1230 bars: int = 8, 1231 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1232 *, 1233 key: typing.Optional[str] = None, 1234 scale: typing.Optional[str] = None, 1235 seed: typing.Optional[int] = None, 1236 rng: typing.Optional[random.Random] = None, 1237 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1238 end: typing.Optional[typing.Any] = None, 1239 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1240 cadence: typing.Optional[str] = None, 1241 dominant_7th: bool = True, 1242 gravity: float = 1.0, 1243 nir_strength: float = 0.5, 1244 minor_turnaround_weight: float = 0.0, 1245 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1246 ) -> "Progression": 1247 1248 """Generate a progression from a chord-graph walk — the hybrid generator. 1249 1250 Full parameter pass-through to the engine (no more throwaway default 1251 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1252 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1253 everywhere. Constraints compile into the walk — a backward 1254 feasibility pass guarantees satisfiability before any chord is 1255 drawn (unsatisfiable constraints raise immediately), then a forward 1256 walk samples through the engine's real history-dependent weights 1257 (NIR, gravity, diversity keep their character). 1258 1259 **Without** ``key=`` the result is key-relative — the walk runs 1260 against a reference tonic and the spans store scale-proof 1261 major-relative romans, so the value prints meaningfully unbound and 1262 resolves wherever it is bound (the walk itself is key-invariant). 1263 **With** ``key=`` the result is concrete. 1264 1265 Parameters: 1266 style: A chord-graph style name (or ``ChordGraph`` instance). 1267 bars: How many chords to generate. 1268 beats: Span length per chord — a scalar, or a list cycled. 1269 key: Key for a concrete result; omit for a key-relative value. 1270 scale: Scale for int constraints' quality inference (e.g. 1271 ``end=1``). Defaults from the style (aeolian_minor → 1272 minor); explicit strings (``"V"``, ``"bVII7"``) never 1273 need it. 1274 seed: Seed for the walk. A standalone generated value without 1275 a seed warns — module-level nondeterminism breaks live 1276 reload. 1277 rng: An explicit random stream (overrides ``seed``). 1278 pins: ``{bar: chord}`` — 1-based; values parse like progression 1279 elements (ints, romans, names, ``Chord``). 1280 end: The chord at the final bar — ``end="V"`` is the cadential 1281 major dominant in minor (a string because it is chromatic; 1282 no int can ask for it). 1283 avoid: Chords excluded from the walk. Naming a chord outside 1284 the style's vocabulary is allowed (trivially satisfied). 1285 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 1286 ``"fakeout"``, theory aliases accepted) — its formula 1287 becomes pins on the final bars, so the walk *approaches* 1288 the close. Conflicts with ``end=`` or pins on those bars. 1289 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1290 root_diversity: The engine parameters, exactly as 1291 :meth:`Composition.harmony` takes them. 1292 1293 Example: 1294 ```python 1295 chorus = subsequence.Progression.generate( 1296 style="aeolian_minor", bars=4, end="V", seed=7, 1297 ) 1298 print(chorus) # romans until bound 1299 ``` 1300 """ 1301 1302 if bars < 1: 1303 raise ValueError("bars must be at least 1") 1304 1305 if cadence is not None: 1306 pins = cadence_pins(cadence, bars, pins, end) 1307 end = None 1308 1309 if rng is None: 1310 if seed is None: 1311 warnings.warn( 1312 "Progression.generate without seed= is nondeterministic — " 1313 "pass seed= so the value survives live reload", 1314 stacklevel = 2, 1315 ) 1316 rng = random.Random() 1317 else: 1318 rng = random.Random(seed) 1319 1320 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1321 relative = key is None 1322 reference = key if key is not None else "C" 1323 1324 state = subsequence.harmonic_state.HarmonicState( 1325 key_name = reference, 1326 graph_style = style, 1327 include_dominant_7th = dominant_7th, 1328 key_gravity_blend = gravity, 1329 nir_strength = nir_strength, 1330 minor_turnaround_weight = minor_turnaround_weight, 1331 root_diversity = root_diversity, 1332 rng = rng, 1333 ) 1334 1335 resolved_pins = { 1336 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1337 for position, spec in (pins or {}).items() 1338 } 1339 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1340 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1341 1342 if 1 in resolved_pins: 1343 if resolved_pins[1] not in state.graph.nodes(): 1344 raise ValueError( 1345 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1346 ) 1347 state.current_chord = resolved_pins[1] 1348 1349 def commit (chosen: subsequence.chords.Chord) -> None: 1350 state.current_chord = chosen 1351 1352 walked = subsequence.sequence_utils.constrained_walk( 1353 state.graph, 1354 state.current_chord, 1355 bars, 1356 rng = state.rng, 1357 pins = resolved_pins, 1358 end = resolved_end, 1359 avoid = resolved_avoid, 1360 weight_modifier = state._transition_weight, 1361 before_choice = state._record_transition_source, 1362 after_choice = commit, 1363 ) 1364 1365 lengths = _span_lengths(beats, bars) 1366 1367 if relative: 1368 return cls(spans = tuple( 1369 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1370 for index, chord in enumerate(walked) 1371 )) 1372 1373 return cls(spans = tuple( 1374 ChordSpan(chord = chord, beats = lengths[index]) 1375 for index, chord in enumerate(walked) 1376 )) 1377 1378 # -- algebra ------------------------------------------------------------ 1379 1380 def __add__ (self, other: "Progression") -> "Progression": 1381 1382 """Concatenate two progressions (the governing ``+``).""" 1383 1384 if not isinstance(other, Progression): 1385 return NotImplemented 1386 1387 return Progression(spans = self.spans + other.spans) 1388 1389 def __mul__ (self, count: int) -> "Progression": 1390 1391 """Tile the spans *count* times.""" 1392 1393 if not isinstance(count, int) or isinstance(count, bool): 1394 return NotImplemented 1395 if count < 1: 1396 raise ValueError("a progression must repeat at least once (n >= 1)") 1397 1398 return Progression(spans = self.spans * count) 1399 1400 def __and__ (self, other: typing.Any) -> "Progression": 1401 1402 """Parallel merge is a type error for governing values — by design.""" 1403 1404 raise TypeError( 1405 "Progressions cannot be merged with & — there is one current chord. " 1406 "Sequence them with +, or give a pattern its own part-level progression." 1407 ) 1408 1409 # -- spice (the five operators) and editing ------------------------------ 1410 1411 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1412 1413 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1414 1415 ``only=`` restricts the spice to the given 1-based chord slots. 1416 """ 1417 1418 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1419 1420 spans = tuple( 1421 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1422 if index in slots else span 1423 for index, span in enumerate(self.spans) 1424 ) 1425 1426 return dataclasses.replace(self, spans=spans) 1427 1428 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1429 1430 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1431 1432 values = [spec] if isinstance(spec, int) else list(spec) 1433 1434 if not values: 1435 raise ValueError("inversions list is empty — pass at least one inversion") 1436 1437 spans = tuple( 1438 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1439 for index, span in enumerate(self.spans) 1440 ) 1441 1442 return dataclasses.replace(self, spans=spans) 1443 1444 def spread (self, style: str) -> "Progression": 1445 1446 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1447 1448 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1449 1450 return dataclasses.replace(self, spans=spans) 1451 1452 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1453 1454 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1455 1456 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A 1457 note name is key-independent, so it resolves to its pitch class right 1458 here; ``"tonic"`` follows the key and stays relative until the 1459 progression is resolved. ``only=`` restricts it to the given 1-based 1460 slots (slash chords rather than a full pedal). 1461 """ 1462 1463 if isinstance(bass, str) and bass != "tonic": 1464 bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now 1465 elif isinstance(bass, int) and not 0 <= bass <= 11: 1466 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1467 1468 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1469 1470 spans = tuple( 1471 dataclasses.replace(span, bass=bass) if index in slots else span 1472 for index, span in enumerate(self.spans) 1473 ) 1474 1475 return dataclasses.replace(self, spans=spans) 1476 1477 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1478 1479 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1480 1481 Modal interchange for key-relative content: the degree re-resolves 1482 against the parallel mode (minor under a major scale and vice 1483 versa). Concrete chords raise — there is nothing relative to borrow. 1484 """ 1485 1486 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1487 1488 spans = list(self.spans) 1489 1490 for index in slots: 1491 chord = spans[index].chord 1492 if not isinstance(chord, RomanChord): 1493 raise ValueError( 1494 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1495 "borrow() needs key-relative content (an int degree or roman)" 1496 ) 1497 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1498 1499 return dataclasses.replace(self, spans=tuple(spans)) 1500 1501 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1502 1503 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1504 1505 index = _check_slot(slot, len(self.spans)) 1506 parsed = parse_element(chord, beats = self.spans[index].beats) 1507 1508 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1509 1510 return dataclasses.replace(self, spans=spans) 1511 1512 def cadence (self, name: str = "strong") -> "Progression": 1513 1514 """Substitute a cadence formula into the tail — the close, named. 1515 1516 The final spans take the formula's chords (``"strong"`` is V→I, 1517 ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — 1518 authentic, plagal, half, deceptive — work as aliases). Each replaced 1519 span keeps its beats; its old chord and decorations go. Formula 1520 chords are key-relative (ints follow the bound scale's qualities, 1521 ``"V"`` is the major dominant by convention), so the tail resolves 1522 wherever the progression is bound — a concrete progression becomes 1523 mixed and resolves its tail at bind time, like any roman content. 1524 1525 Example:: 1526 1527 verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") 1528 # Bound in A minor: Am F Dm E — the half close, hanging on the dominant 1529 1530 Raises: 1531 ValueError: If the cadence name is unknown, or the progression 1532 has fewer spans than the formula. 1533 """ 1534 1535 spec = subsequence.cadences.cadence_formula(name) 1536 count = len(spec.formula) 1537 1538 if len(self.spans) < count: 1539 raise ValueError( 1540 f"cadence({name!r}) substitutes the last {count} chords, but this " 1541 f"progression has only {len(self.spans)}" 1542 ) 1543 1544 tail = tuple( 1545 parse_element(element, beats = span.beats) 1546 for element, span in zip(spec.formula, self.spans[-count:]) 1547 ) 1548 1549 return dataclasses.replace(self, spans = self.spans[:-count] + tail) 1550 1551 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1552 1553 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1554 1555 if isinstance(beats, bool): 1556 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1557 1558 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1559 1560 if not values: 1561 raise ValueError("with_rhythm list is empty — pass at least one length") 1562 1563 spans = tuple( 1564 dataclasses.replace(span, beats = float(values[index % len(values)])) 1565 for index, span in enumerate(self.spans) 1566 ) 1567 1568 return dataclasses.replace(self, spans=spans) 1569 1570 def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": 1571 1572 """Steedman-inspired chord elaboration — approach each chord by fifths. 1573 1574 Implements the heart of Mark Steedman's generative grammar for 1575 jazz/blues chord sequences: every chord is **approached** by a chain 1576 of secondary dominants propagated backward around the cycle of fifths 1577 (Rule 3, "the perfect cadence propagated backward"), carved out of that 1578 chord's own span (Rule 1, metric subdivision). ``depth`` is literally 1579 how many fifth-steps back the chain extends: 1580 1581 - ``depth=0`` — identity (the bare progression). 1582 - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → 1583 ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). 1584 - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). 1585 - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord 1586 is made minor — the ``ii`` of *its own local dominant* (the next 1587 link in the chain), forming a ii–V into that link, not the 1588 target's own ii — and dominants are recoloured by **tritone 1589 substitution** with even odds (Rule 4) for chromatic descents. 1590 This tritone choice is the only nondeterministic part, so ``seed`` 1591 is taken (or warned) at depth ≥ 3. 1592 1593 Its flagship is the 12-bar blues with depth-per-chorus — elaborate a 1594 ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and 1595 tritone subs accumulate. 1596 1597 The progression must be **concrete** (resolved to rooted chords); 1598 the inserted dominants are computed by pitch-class arithmetic. Each 1599 chord keeps its decorations on the final (resolved) sub-span; the 1600 inserted approach chords are bare dominant/minor sevenths. Note that 1601 each span is divided into ``depth + 1`` equal sub-spans, so deep 1602 elaboration of a short harmonic rhythm can drop sub-spans below the 1603 harmony clock's lookahead floor — which raises at ``play()``/ 1604 ``render()`` if the result is bound to the global clock (it is free 1605 of that floor at the part level, ``p.progression()``). 1606 1607 Parameters: 1608 depth: Elaboration depth (≥ 0). 1609 seed: Seed for the depth-≥3 tritone-substitution choices. 1610 1611 Returns: 1612 A new :class:`Progression` with the approach chords inserted. 1613 1614 Raises: 1615 ValueError: If *depth* is negative, the progression is 1616 key-relative, or any span is a rootless 1617 :class:`PitchSet`. 1618 1619 Example: 1620 ```python 1621 blues = subsequence.progression("twelve_bar_blues").resolve("C") 1622 chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout 1623 ``` 1624 """ 1625 1626 if depth < 0: 1627 raise ValueError("elaborate depth must be at least 0") 1628 1629 self._require_concrete("elaborate") 1630 1631 if depth == 0: 1632 return self 1633 1634 for span in self.spans: 1635 if isinstance(span.chord, PitchSet): 1636 raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") 1637 1638 if depth >= 3 and seed is None: 1639 warnings.warn( 1640 "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " 1641 "result survives live reload", 1642 stacklevel = 2, 1643 ) 1644 1645 rng = random.Random(seed) 1646 new_spans: typing.List[ChordSpan] = [] 1647 1648 for span in self.spans: 1649 1650 target_root = span.chord.root_pc 1651 sub_beats = span.beats / (depth + 1) 1652 1653 # The backward cycle-of-fifths chain, furthest-back first: chord j 1654 # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The 1655 # furthest-back (j == depth) is made minor — the ii of its OWN 1656 # local dominant (the next link), forming a ii–V into that link — 1657 # once the chain is long enough (depth >= 2) to spell one. 1658 for j in range(depth, 0, -1): 1659 root = (target_root + 7 * j) % 12 1660 quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" 1661 1662 # Tritone substitution recolours a dominant to the dom7 a 1663 # tritone away (same guide tones, chromatic resolution). 1664 if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: 1665 root = (root + 6) % 12 1666 1667 new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) 1668 1669 # The target keeps its own chord and decorations, on its sub-span. 1670 new_spans.append(dataclasses.replace(span, beats = sub_beats)) 1671 1672 return dataclasses.replace(self, spans = tuple(new_spans)) 1673 1674 # -- description ---------------------------------------------------------- 1675 1676 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1677 1678 """A readable, one-chord-per-line summary. 1679 1680 Key-relative spans print as written (romans/degrees) when unbound, 1681 and as concrete chord names under a *key*. 1682 """ 1683 1684 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1685 1686 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1687 cursor = 0.0 1688 1689 for span in self.spans: 1690 lines.append( 1691 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1692 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1693 ) 1694 cursor += span.beats 1695 1696 return "\n".join(lines) 1697 1698 def __str__ (self) -> str: 1699 1700 """Same as :meth:`describe` with no key bound.""" 1701 1702 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.
1130 @property 1131 def length (self) -> float: 1132 1133 """Total length in beats (the sum of span lengths).""" 1134 1135 return float(sum(span.beats for span in self.spans))
Total length in beats (the sum of span lengths).
1137 @property 1138 def is_concrete (self) -> bool: 1139 1140 """True when every span is key-independent (no romans/degrees).""" 1141 1142 return all(span.is_concrete for span in self.spans)
True when every span is key-independent (no romans/degrees).
1144 @property 1145 def chords (self) -> typing.Tuple[typing.Any, ...]: 1146 1147 """The bare chords, one per span (concrete progressions only).""" 1148 1149 self._require_concrete("read .chords") 1150 1151 return tuple(span.chord for span in self.spans)
The bare chords, one per span (concrete progressions only).
1153 @property 1154 def loops_on_exhaustion (self) -> bool: 1155 1156 """True when the clock must loop rather than fall through to live stepping.""" 1157 1158 return any(isinstance(span.chord, PitchSet) for span in self.spans)
True when the clock must loop rather than fall through to live stepping.
1190 def events (self) -> typing.Tuple[ChordEvent, ...]: 1191 1192 """The realised timeline as a tuple (iteration, materialised).""" 1193 1194 return tuple(self)
The realised timeline as a tuple (iteration, materialised).
1196 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 1197 1198 """Return ``(span, start, end)`` for the span sounding at *beat*. 1199 1200 *beat* wraps modulo the progression length, so the lookup also 1201 serves looped playback. 1202 """ 1203 1204 position = beat % self.length 1205 cursor = 0.0 1206 1207 for span in self.spans: 1208 if cursor <= position < cursor + span.beats: 1209 return span, cursor, cursor + span.beats 1210 cursor += span.beats 1211 1212 final = self.spans[-1] 1213 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.
1215 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 1216 1217 """Resolve every key-relative span against a key (name or pitch class).""" 1218 1219 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 1220 1221 return dataclasses.replace( 1222 self, 1223 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1224 )
Resolve every key-relative span against a key (name or pitch class).
1226 @classmethod 1227 def generate ( 1228 cls, 1229 style: typing.Union[str, typing.Any] = "functional_major", 1230 bars: int = 8, 1231 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1232 *, 1233 key: typing.Optional[str] = None, 1234 scale: typing.Optional[str] = None, 1235 seed: typing.Optional[int] = None, 1236 rng: typing.Optional[random.Random] = None, 1237 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1238 end: typing.Optional[typing.Any] = None, 1239 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1240 cadence: typing.Optional[str] = None, 1241 dominant_7th: bool = True, 1242 gravity: float = 1.0, 1243 nir_strength: float = 0.5, 1244 minor_turnaround_weight: float = 0.0, 1245 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1246 ) -> "Progression": 1247 1248 """Generate a progression from a chord-graph walk — the hybrid generator. 1249 1250 Full parameter pass-through to the engine (no more throwaway default 1251 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1252 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1253 everywhere. Constraints compile into the walk — a backward 1254 feasibility pass guarantees satisfiability before any chord is 1255 drawn (unsatisfiable constraints raise immediately), then a forward 1256 walk samples through the engine's real history-dependent weights 1257 (NIR, gravity, diversity keep their character). 1258 1259 **Without** ``key=`` the result is key-relative — the walk runs 1260 against a reference tonic and the spans store scale-proof 1261 major-relative romans, so the value prints meaningfully unbound and 1262 resolves wherever it is bound (the walk itself is key-invariant). 1263 **With** ``key=`` the result is concrete. 1264 1265 Parameters: 1266 style: A chord-graph style name (or ``ChordGraph`` instance). 1267 bars: How many chords to generate. 1268 beats: Span length per chord — a scalar, or a list cycled. 1269 key: Key for a concrete result; omit for a key-relative value. 1270 scale: Scale for int constraints' quality inference (e.g. 1271 ``end=1``). Defaults from the style (aeolian_minor → 1272 minor); explicit strings (``"V"``, ``"bVII7"``) never 1273 need it. 1274 seed: Seed for the walk. A standalone generated value without 1275 a seed warns — module-level nondeterminism breaks live 1276 reload. 1277 rng: An explicit random stream (overrides ``seed``). 1278 pins: ``{bar: chord}`` — 1-based; values parse like progression 1279 elements (ints, romans, names, ``Chord``). 1280 end: The chord at the final bar — ``end="V"`` is the cadential 1281 major dominant in minor (a string because it is chromatic; 1282 no int can ask for it). 1283 avoid: Chords excluded from the walk. Naming a chord outside 1284 the style's vocabulary is allowed (trivially satisfied). 1285 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 1286 ``"fakeout"``, theory aliases accepted) — its formula 1287 becomes pins on the final bars, so the walk *approaches* 1288 the close. Conflicts with ``end=`` or pins on those bars. 1289 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1290 root_diversity: The engine parameters, exactly as 1291 :meth:`Composition.harmony` takes them. 1292 1293 Example: 1294 ```python 1295 chorus = subsequence.Progression.generate( 1296 style="aeolian_minor", bars=4, end="V", seed=7, 1297 ) 1298 print(chorus) # romans until bound 1299 ``` 1300 """ 1301 1302 if bars < 1: 1303 raise ValueError("bars must be at least 1") 1304 1305 if cadence is not None: 1306 pins = cadence_pins(cadence, bars, pins, end) 1307 end = None 1308 1309 if rng is None: 1310 if seed is None: 1311 warnings.warn( 1312 "Progression.generate without seed= is nondeterministic — " 1313 "pass seed= so the value survives live reload", 1314 stacklevel = 2, 1315 ) 1316 rng = random.Random() 1317 else: 1318 rng = random.Random(seed) 1319 1320 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1321 relative = key is None 1322 reference = key if key is not None else "C" 1323 1324 state = subsequence.harmonic_state.HarmonicState( 1325 key_name = reference, 1326 graph_style = style, 1327 include_dominant_7th = dominant_7th, 1328 key_gravity_blend = gravity, 1329 nir_strength = nir_strength, 1330 minor_turnaround_weight = minor_turnaround_weight, 1331 root_diversity = root_diversity, 1332 rng = rng, 1333 ) 1334 1335 resolved_pins = { 1336 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1337 for position, spec in (pins or {}).items() 1338 } 1339 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1340 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1341 1342 if 1 in resolved_pins: 1343 if resolved_pins[1] not in state.graph.nodes(): 1344 raise ValueError( 1345 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1346 ) 1347 state.current_chord = resolved_pins[1] 1348 1349 def commit (chosen: subsequence.chords.Chord) -> None: 1350 state.current_chord = chosen 1351 1352 walked = subsequence.sequence_utils.constrained_walk( 1353 state.graph, 1354 state.current_chord, 1355 bars, 1356 rng = state.rng, 1357 pins = resolved_pins, 1358 end = resolved_end, 1359 avoid = resolved_avoid, 1360 weight_modifier = state._transition_weight, 1361 before_choice = state._record_transition_source, 1362 after_choice = commit, 1363 ) 1364 1365 lengths = _span_lengths(beats, bars) 1366 1367 if relative: 1368 return cls(spans = tuple( 1369 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1370 for index, chord in enumerate(walked) 1371 )) 1372 1373 return cls(spans = tuple( 1374 ChordSpan(chord = chord, beats = lengths[index]) 1375 for index, chord in enumerate(walked) 1376 ))
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
1411 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1412 1413 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1414 1415 ``only=`` restricts the spice to the given 1-based chord slots. 1416 """ 1417 1418 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1419 1420 spans = tuple( 1421 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1422 if index in slots else span 1423 for index, span in enumerate(self.spans) 1424 ) 1425 1426 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.
1428 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1429 1430 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1431 1432 values = [spec] if isinstance(spec, int) else list(spec) 1433 1434 if not values: 1435 raise ValueError("inversions list is empty — pass at least one inversion") 1436 1437 spans = tuple( 1438 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1439 for index, span in enumerate(self.spans) 1440 ) 1441 1442 return dataclasses.replace(self, spans=spans)
Set chord inversions — a single int for all spans, or a list cycled per span.
1444 def spread (self, style: str) -> "Progression": 1445 1446 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1447 1448 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1449 1450 return dataclasses.replace(self, spans=spans)
Set the voicing spread: "close", "open" (drop-2), or "wide".
1452 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1453 1454 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1455 1456 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A 1457 note name is key-independent, so it resolves to its pitch class right 1458 here; ``"tonic"`` follows the key and stays relative until the 1459 progression is resolved. ``only=`` restricts it to the given 1-based 1460 slots (slash chords rather than a full pedal). 1461 """ 1462 1463 if isinstance(bass, str) and bass != "tonic": 1464 bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now 1465 elif isinstance(bass, int) and not 0 <= bass <= 11: 1466 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1467 1468 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1469 1470 spans = tuple( 1471 dataclasses.replace(span, bass=bass) if index in slots else span 1472 for index, span in enumerate(self.spans) 1473 ) 1474 1475 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).
1477 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1478 1479 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1480 1481 Modal interchange for key-relative content: the degree re-resolves 1482 against the parallel mode (minor under a major scale and vice 1483 versa). Concrete chords raise — there is nothing relative to borrow. 1484 """ 1485 1486 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1487 1488 spans = list(self.spans) 1489 1490 for index in slots: 1491 chord = spans[index].chord 1492 if not isinstance(chord, RomanChord): 1493 raise ValueError( 1494 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1495 "borrow() needs key-relative content (an int degree or roman)" 1496 ) 1497 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1498 1499 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.
1501 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1502 1503 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1504 1505 index = _check_slot(slot, len(self.spans)) 1506 parsed = parse_element(chord, beats = self.spans[index].beats) 1507 1508 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1509 1510 return dataclasses.replace(self, spans=spans)
Replace the chord at a 1-based slot (the span keeps its beats).
1512 def cadence (self, name: str = "strong") -> "Progression": 1513 1514 """Substitute a cadence formula into the tail — the close, named. 1515 1516 The final spans take the formula's chords (``"strong"`` is V→I, 1517 ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — 1518 authentic, plagal, half, deceptive — work as aliases). Each replaced 1519 span keeps its beats; its old chord and decorations go. Formula 1520 chords are key-relative (ints follow the bound scale's qualities, 1521 ``"V"`` is the major dominant by convention), so the tail resolves 1522 wherever the progression is bound — a concrete progression becomes 1523 mixed and resolves its tail at bind time, like any roman content. 1524 1525 Example:: 1526 1527 verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") 1528 # Bound in A minor: Am F Dm E — the half close, hanging on the dominant 1529 1530 Raises: 1531 ValueError: If the cadence name is unknown, or the progression 1532 has fewer spans than the formula. 1533 """ 1534 1535 spec = subsequence.cadences.cadence_formula(name) 1536 count = len(spec.formula) 1537 1538 if len(self.spans) < count: 1539 raise ValueError( 1540 f"cadence({name!r}) substitutes the last {count} chords, but this " 1541 f"progression has only {len(self.spans)}" 1542 ) 1543 1544 tail = tuple( 1545 parse_element(element, beats = span.beats) 1546 for element, span in zip(spec.formula, self.spans[-count:]) 1547 ) 1548 1549 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.
1551 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1552 1553 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1554 1555 if isinstance(beats, bool): 1556 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1557 1558 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1559 1560 if not values: 1561 raise ValueError("with_rhythm list is empty — pass at least one length") 1562 1563 spans = tuple( 1564 dataclasses.replace(span, beats = float(values[index % len(values)])) 1565 for index, span in enumerate(self.spans) 1566 ) 1567 1568 return dataclasses.replace(self, spans=spans)
Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.
1570 def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": 1571 1572 """Steedman-inspired chord elaboration — approach each chord by fifths. 1573 1574 Implements the heart of Mark Steedman's generative grammar for 1575 jazz/blues chord sequences: every chord is **approached** by a chain 1576 of secondary dominants propagated backward around the cycle of fifths 1577 (Rule 3, "the perfect cadence propagated backward"), carved out of that 1578 chord's own span (Rule 1, metric subdivision). ``depth`` is literally 1579 how many fifth-steps back the chain extends: 1580 1581 - ``depth=0`` — identity (the bare progression). 1582 - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → 1583 ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). 1584 - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). 1585 - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord 1586 is made minor — the ``ii`` of *its own local dominant* (the next 1587 link in the chain), forming a ii–V into that link, not the 1588 target's own ii — and dominants are recoloured by **tritone 1589 substitution** with even odds (Rule 4) for chromatic descents. 1590 This tritone choice is the only nondeterministic part, so ``seed`` 1591 is taken (or warned) at depth ≥ 3. 1592 1593 Its flagship is the 12-bar blues with depth-per-chorus — elaborate a 1594 ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and 1595 tritone subs accumulate. 1596 1597 The progression must be **concrete** (resolved to rooted chords); 1598 the inserted dominants are computed by pitch-class arithmetic. Each 1599 chord keeps its decorations on the final (resolved) sub-span; the 1600 inserted approach chords are bare dominant/minor sevenths. Note that 1601 each span is divided into ``depth + 1`` equal sub-spans, so deep 1602 elaboration of a short harmonic rhythm can drop sub-spans below the 1603 harmony clock's lookahead floor — which raises at ``play()``/ 1604 ``render()`` if the result is bound to the global clock (it is free 1605 of that floor at the part level, ``p.progression()``). 1606 1607 Parameters: 1608 depth: Elaboration depth (≥ 0). 1609 seed: Seed for the depth-≥3 tritone-substitution choices. 1610 1611 Returns: 1612 A new :class:`Progression` with the approach chords inserted. 1613 1614 Raises: 1615 ValueError: If *depth* is negative, the progression is 1616 key-relative, or any span is a rootless 1617 :class:`PitchSet`. 1618 1619 Example: 1620 ```python 1621 blues = subsequence.progression("twelve_bar_blues").resolve("C") 1622 chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout 1623 ``` 1624 """ 1625 1626 if depth < 0: 1627 raise ValueError("elaborate depth must be at least 0") 1628 1629 self._require_concrete("elaborate") 1630 1631 if depth == 0: 1632 return self 1633 1634 for span in self.spans: 1635 if isinstance(span.chord, PitchSet): 1636 raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") 1637 1638 if depth >= 3 and seed is None: 1639 warnings.warn( 1640 "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " 1641 "result survives live reload", 1642 stacklevel = 2, 1643 ) 1644 1645 rng = random.Random(seed) 1646 new_spans: typing.List[ChordSpan] = [] 1647 1648 for span in self.spans: 1649 1650 target_root = span.chord.root_pc 1651 sub_beats = span.beats / (depth + 1) 1652 1653 # The backward cycle-of-fifths chain, furthest-back first: chord j 1654 # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The 1655 # furthest-back (j == depth) is made minor — the ii of its OWN 1656 # local dominant (the next link), forming a ii–V into that link — 1657 # once the chain is long enough (depth >= 2) to spell one. 1658 for j in range(depth, 0, -1): 1659 root = (target_root + 7 * j) % 12 1660 quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" 1661 1662 # Tritone substitution recolours a dominant to the dom7 a 1663 # tritone away (same guide tones, chromatic resolution). 1664 if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: 1665 root = (root + 6) % 12 1666 1667 new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) 1668 1669 # The target keeps its own chord and decorations, on its sub-span. 1670 new_spans.append(dataclasses.replace(span, beats = sub_beats)) 1671 1672 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
1676 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1677 1678 """A readable, one-chord-per-line summary. 1679 1680 Key-relative spans print as written (romans/degrees) when unbound, 1681 and as concrete chord names under a *key*. 1682 """ 1683 1684 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1685 1686 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1687 cursor = 0.0 1688 1689 for span in self.spans: 1690 lines.append( 1691 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1692 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1693 ) 1694 cursor += span.beats 1695 1696 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.
495@dataclasses.dataclass(frozen=True) 496class ChordSpan: 497 498 """One chord with a duration and its decoration — the unit of harmonic time. 499 500 Decoration (extensions, slash bass, inversion, spread) lives HERE, never 501 on :class:`~subsequence.chords.Chord`: the engine's graph identity stays 502 the bare triad, and the decorated voicing is what patterns hear. 503 504 Attributes: 505 chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a 506 :class:`PitchSet`. 507 beats: Span length in beats. 508 extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) 509 or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). 510 bass: Slash/pedal bass — a pitch class int, a note name, or 511 ``"tonic"`` (resolved against the key at query time). 512 inversion: Chord inversion for the voicing (0 = root position). 513 spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), 514 or ``"wide"`` (drop-2-and-4). 515 extension_intervals: Pre-computed semitone offsets for the 516 extensions, set by :meth:`Progression.resolve` for diatonic 517 degrees. ``None`` means "derive from the chord's own colour". 518 """ 519 520 chord: typing.Any 521 beats: float 522 extensions: typing.Tuple[typing.Any, ...] = () 523 bass: typing.Optional[typing.Union[int, str]] = None 524 inversion: int = 0 525 spread: typing.Optional[str] = None 526 extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None 527 528 def __post_init__ (self) -> None: 529 530 """Validate beats, extensions, and spread.""" 531 532 if self.beats <= 0: 533 raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}") 534 535 for extension in self.extensions: 536 if isinstance(extension, bool) or not ( 537 (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) 538 or (isinstance(extension, str) and extension in _EXTENSION_NAMES) 539 ): 540 known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) 541 raise ValueError(f"unknown extension {extension!r} — expected one of: {known}") 542 543 if self.spread is not None and self.spread not in _SPREAD_STYLES: 544 raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES))) 545 546 @property 547 def is_concrete (self) -> bool: 548 549 """True when the chord (and any pedal bass) needs no key context to sound. 550 551 A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not 552 concrete until :meth:`resolve` pins it to a key. Note-name basses are 553 resolved to a pitch class eagerly in :meth:`Progression.over`, so they 554 never linger here as strings. 555 """ 556 557 return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str) 558 559 @property 560 def is_decorated (self) -> bool: 561 562 """True when the span carries any decoration beyond the bare chord.""" 563 564 return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None 565 566 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 567 568 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 569 570 chord = self.chord 571 extension_intervals = self.extension_intervals 572 573 if isinstance(chord, RomanChord): 574 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 575 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 576 chord = chord.resolve(key_pc, scale) 577 578 bass: typing.Optional[typing.Union[int, str]] = self.bass 579 580 if isinstance(bass, str): 581 if bass == "tonic": 582 bass = key_pc 583 else: 584 bass = subsequence.chords.key_name_to_pc(bass) 585 586 return dataclasses.replace( 587 self, 588 chord = chord, 589 bass = bass, 590 extension_intervals = extension_intervals, 591 ) 592 593 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 594 595 """A printable chord label: roman text when relative, decorated name when concrete.""" 596 597 if isinstance(self.chord, RomanChord): 598 if key_pc is None: 599 text = self.chord.label() 600 return text + self._decoration_suffix(resolved=False) 601 return self.resolve(key_pc, scale).label() 602 603 stacked_name = self._stacked_chord_name() 604 605 if stacked_name is not None: 606 return stacked_name + self._decoration_suffix(resolved=True, stacked=False) 607 608 base = str(self.chord.name()) 609 return base + self._decoration_suffix(resolved=True) 610 611 def _stacked_chord_name (self) -> typing.Optional[str]: 612 613 """The chord's printed name when a stacked extension changes which chord it is. 614 615 ``extend(7)`` deepens a chord in its own colour, so C major gains a 616 *major* seventh — and has to print ``Cmaj7``, because ``C7`` names a 617 dominant seventh, a different chord. Naming the result rather than 618 gluing the number onto the triad also keeps the leading-tone chord 619 honest: its diatonic seventh is half-diminished (``Bm7b5``), not the 620 fully diminished ``Bdim7``. 621 622 Returns ``None`` for shapes this cannot identify — a registered custom 623 quality, a pitch set — so the caller falls back to the plain suffix 624 rather than inventing a name. 625 """ 626 627 if not isinstance(self.chord, subsequence.chords.Chord): 628 return None 629 630 stacked = [e for e in self.extensions if isinstance(e, int) and e in _NUMERIC_EXTENSIONS] 631 632 if not stacked: 633 return None 634 635 intervals = list(self.chord.intervals()) 636 637 if len(intervals) < 3: 638 return None 639 640 # Mirror decorated_intervals(): a sus extension replaces the third, so 641 # the shape has to be read after that substitution, not before. 642 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 643 644 if sus: 645 intervals[1] = 2 if sus[0] == "sus2" else 5 646 647 shape = _TRIAD_SHAPES.get((intervals[1], intervals[2])) 648 649 if shape is None: 650 return None 651 652 # 9 implies 7 (and so on up): the highest stacked extension names the chord. 653 top = max(stacked) 654 seventh = next((i for i in self.decorated_intervals() if i in (9, 10, 11)), None) 655 root_name = subsequence.chords.PC_TO_NOTE_NAME[self.chord.root_pc % 12] 656 657 if shape == "diminished": 658 tail = f"m{top}b5" if seventh == 10 else f"dim{top}" 659 660 elif shape == "minor": 661 tail = f"mMaj{top}" if seventh == 11 else f"m{top}" 662 663 elif shape == "augmented": 664 tail = f"+maj{top}" if seventh == 11 else f"+{top}" 665 666 elif shape == "major": 667 tail = f"maj{top}" if seventh == 11 else str(top) 668 669 else: 670 # Suspensions follow the number (C7sus4). One that arrived as an 671 # extension is printed by the extension loop below, so only a 672 # suspended *quality* spells itself here. 673 tail = str(top) if sus else f"{top}{shape}" 674 675 return root_name + tail 676 677 def _decoration_suffix (self, resolved: bool, stacked: bool = True) -> str: 678 679 """The printable decoration tail (extensions and slash bass). 680 681 ``stacked=False`` leaves the numeric extension out, for when 682 :meth:`_stacked_chord_name` has already spelled it into the name. 683 """ 684 685 parts = "" 686 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 687 688 # 9 implies 7 (and so on up): print only the highest stacked extension. 689 stacked_extensions = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 690 if stacked and stacked_extensions: 691 parts += str(stacked_extensions[-1]) 692 693 for name in (e for e in self.extensions if isinstance(e, str)): 694 parts += name 695 696 if self.bass is not None: 697 if isinstance(self.bass, int): 698 parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] 699 else: 700 parts += "/" + str(self.bass) 701 702 return parts 703 704 def decorated_intervals (self) -> typing.List[int]: 705 706 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 707 708 Numeric extensions deepen the chord in its own colour — a minor third 709 gets a minor seventh, a major third a major seventh, a diminished 710 triad a diminished seventh. Diatonic degrees extended with 711 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 712 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 713 dominant colour on a concrete major chord. 714 """ 715 716 if isinstance(self.chord, RomanChord): 717 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 718 719 intervals = list(self.chord.intervals()) 720 721 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 722 if sus and len(intervals) >= 2: 723 intervals[1] = 2 if sus[0] == "sus2" else 5 724 725 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 726 727 if self.extension_intervals is not None: 728 added: typing.List[int] = list(self.extension_intervals) 729 else: 730 added = [] 731 third = intervals[1] if len(intervals) >= 2 else None 732 has_seventh = any(i in (9, 10, 11) for i in intervals) 733 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 734 735 if stacked and not has_seventh: 736 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 737 added.append(9) # diminished colour 738 elif third == 3: 739 added.append(10) # minor colour 740 elif third == 4: 741 added.append(11) # major colour 742 else: 743 added.append(10) # sus / no third: the dominant-leaning seventh 744 745 for extension in stacked: 746 if extension == 9: 747 added.append(14) 748 elif extension == 11: 749 added.append(17) 750 elif extension == 13: 751 added.append(21) 752 753 if "add9" in self.extensions: 754 added.append(14) 755 if "6" in self.extensions: 756 added.append(9) 757 758 return sorted(set(intervals) | set(added)) 759 760 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 761 762 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 763 764 Applies, in order: extensions, inversion, spread, then the slash/pedal 765 bass below the voicing. ``PitchSet`` spans return their absolute 766 pitches (decoration other than ``count`` does not apply). 767 """ 768 769 if isinstance(self.chord, RomanChord): 770 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 771 772 if isinstance(self.chord, PitchSet): 773 return self.chord.tones(root, inversion=self.inversion, count=count) 774 775 intervals = self.decorated_intervals() 776 777 if self.inversion != 0: 778 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 779 780 if self.spread == "open" and len(intervals) >= 3: 781 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 782 elif self.spread == "wide" and len(intervals) >= 3: 783 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 784 intervals = sorted(dropped) 785 786 offset = (self.chord.root_pc - root) % 12 787 if offset > 6: 788 offset -= 12 789 effective_root = root + offset 790 791 if count is not None: 792 n = len(intervals) 793 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 794 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 795 else: 796 pitches = [effective_root + interval for interval in intervals] 797 798 if self.bass is not None and isinstance(self.bass, int): 799 lowest = min(pitches) 800 bass_note = lowest - ((lowest - self.bass) % 12) 801 if bass_note == lowest: 802 bass_note -= 12 803 pitches = [bass_note] + pitches 804 805 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".
546 @property 547 def is_concrete (self) -> bool: 548 549 """True when the chord (and any pedal bass) needs no key context to sound. 550 551 A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not 552 concrete until :meth:`resolve` pins it to a key. Note-name basses are 553 resolved to a pitch class eagerly in :meth:`Progression.over`, so they 554 never linger here as strings. 555 """ 556 557 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.
559 @property 560 def is_decorated (self) -> bool: 561 562 """True when the span carries any decoration beyond the bare chord.""" 563 564 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.
566 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 567 568 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 569 570 chord = self.chord 571 extension_intervals = self.extension_intervals 572 573 if isinstance(chord, RomanChord): 574 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 575 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 576 chord = chord.resolve(key_pc, scale) 577 578 bass: typing.Optional[typing.Union[int, str]] = self.bass 579 580 if isinstance(bass, str): 581 if bass == "tonic": 582 bass = key_pc 583 else: 584 bass = subsequence.chords.key_name_to_pc(bass) 585 586 return dataclasses.replace( 587 self, 588 chord = chord, 589 bass = bass, 590 extension_intervals = extension_intervals, 591 )
Return a concrete span: romans resolved, bass resolved to a pitch class.
593 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 594 595 """A printable chord label: roman text when relative, decorated name when concrete.""" 596 597 if isinstance(self.chord, RomanChord): 598 if key_pc is None: 599 text = self.chord.label() 600 return text + self._decoration_suffix(resolved=False) 601 return self.resolve(key_pc, scale).label() 602 603 stacked_name = self._stacked_chord_name() 604 605 if stacked_name is not None: 606 return stacked_name + self._decoration_suffix(resolved=True, stacked=False) 607 608 base = str(self.chord.name()) 609 return base + self._decoration_suffix(resolved=True)
A printable chord label: roman text when relative, decorated name when concrete.
704 def decorated_intervals (self) -> typing.List[int]: 705 706 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 707 708 Numeric extensions deepen the chord in its own colour — a minor third 709 gets a minor seventh, a major third a major seventh, a diminished 710 triad a diminished seventh. Diatonic degrees extended with 711 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 712 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 713 dominant colour on a concrete major chord. 714 """ 715 716 if isinstance(self.chord, RomanChord): 717 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 718 719 intervals = list(self.chord.intervals()) 720 721 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 722 if sus and len(intervals) >= 2: 723 intervals[1] = 2 if sus[0] == "sus2" else 5 724 725 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 726 727 if self.extension_intervals is not None: 728 added: typing.List[int] = list(self.extension_intervals) 729 else: 730 added = [] 731 third = intervals[1] if len(intervals) >= 2 else None 732 has_seventh = any(i in (9, 10, 11) for i in intervals) 733 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 734 735 if stacked and not has_seventh: 736 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 737 added.append(9) # diminished colour 738 elif third == 3: 739 added.append(10) # minor colour 740 elif third == 4: 741 added.append(11) # major colour 742 else: 743 added.append(10) # sus / no third: the dominant-leaning seventh 744 745 for extension in stacked: 746 if extension == 9: 747 added.append(14) 748 elif extension == 11: 749 added.append(17) 750 elif extension == 13: 751 added.append(21) 752 753 if "add9" in self.extensions: 754 added.append(14) 755 if "6" in self.extensions: 756 added.append(9) 757 758 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.
760 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 761 762 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 763 764 Applies, in order: extensions, inversion, spread, then the slash/pedal 765 bass below the voicing. ``PitchSet`` spans return their absolute 766 pitches (decoration other than ``count`` does not apply). 767 """ 768 769 if isinstance(self.chord, RomanChord): 770 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 771 772 if isinstance(self.chord, PitchSet): 773 return self.chord.tones(root, inversion=self.inversion, count=count) 774 775 intervals = self.decorated_intervals() 776 777 if self.inversion != 0: 778 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 779 780 if self.spread == "open" and len(intervals) >= 3: 781 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 782 elif self.spread == "wide" and len(intervals) >= 3: 783 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 784 intervals = sorted(dropped) 785 786 offset = (self.chord.root_pc - root) % 12 787 if offset > 6: 788 offset -= 12 789 effective_root = root + offset 790 791 if count is not None: 792 n = len(intervals) 793 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 794 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 795 else: 796 pitches = [effective_root + interval for interval in intervals] 797 798 if self.bass is not None and isinstance(self.bass, int): 799 lowest = min(pitches) 800 bass_note = lowest - ((lowest - self.bass) % 12) 801 if bass_note == lowest: 802 bass_note -= 12 803 pitches = [bass_note] + pitches 804 805 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.
1728def progression ( 1729 source: typing.Optional[typing.Any] = None, 1730 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1731 *, 1732 style: typing.Optional[str] = None, 1733 bars: int = 8, 1734 key: typing.Optional[str] = None, 1735 scale: typing.Optional[str] = None, 1736 seed: typing.Optional[int] = None, 1737 rng: typing.Optional[random.Random] = None, 1738 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1739 end: typing.Optional[typing.Any] = None, 1740 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1741 cadence: typing.Optional[str] = None, 1742 dominant_7th: bool = True, 1743 gravity: float = 1.0, 1744 nir_strength: float = 0.5, 1745 minor_turnaround_weight: float = 0.0, 1746 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1747) -> Progression: 1748 1749 """Build a :class:`Progression` — the lowercase factory. 1750 1751 Dispatch by argument type: a **list** parses per element (ints where 1752 diatonic, name/roman strings where nominal/chromatic, 1753 ``(element, beats)`` tuples for per-chord durations); a bare **string** names a 1754 preset from the curated table; ``style=`` generates *bars* chords from a 1755 chord-graph walk (requires ``key=``). 1756 1757 Parameters: 1758 source: The element list, preset name, or an existing Progression 1759 (returned unchanged). 1760 beats: Span length per chord — a scalar, or a list cycled per chord 1761 (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). 1762 style: A chord-graph style name to generate from (e.g. 1763 ``"aeolian_minor"``). 1764 bars: How many chords to generate (style mode only). 1765 key: Key for style generation. 1766 seed: Seed for style generation. A standalone generated value 1767 without a seed warns — module-level nondeterminism breaks live 1768 reload. 1769 rng: An explicit random stream (overrides ``seed``; used by 1770 engine-mediated calls). 1771 dominant_7th / gravity / nir_strength: Graph-walk parameters, 1772 matching :meth:`Composition.harmony` (style mode only; full 1773 pass-through arrives with ``Progression.generate``). 1774 1775 Example: 1776 ```python 1777 verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor 1778 blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) 1779 walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) 1780 ``` 1781 """ 1782 1783 if style is not None: 1784 if source is not None: 1785 raise ValueError("pass either source or style=, not both") 1786 return Progression.generate( 1787 style = style, 1788 bars = bars, 1789 beats = beats, 1790 key = key, 1791 scale = scale, 1792 seed = seed, 1793 rng = rng, 1794 pins = pins, 1795 end = end, 1796 avoid = avoid, 1797 cadence = cadence, 1798 dominant_7th = dominant_7th, 1799 gravity = gravity, 1800 nir_strength = nir_strength, 1801 minor_turnaround_weight = minor_turnaround_weight, 1802 root_diversity = root_diversity, 1803 ) 1804 1805 # Generation-only knobs are meaningless for a concrete source — reject 1806 # them so a musician asking for cadence= or key= on a list gets a usable 1807 # error instead of a silent no-op. 1808 generation_only = { 1809 "bars": bars != 8, 1810 "key": key is not None, 1811 "scale": scale is not None, 1812 "seed": seed is not None, 1813 "rng": rng is not None, 1814 "pins": pins is not None, 1815 "end": end is not None, 1816 "avoid": avoid is not None, 1817 "cadence": cadence is not None, 1818 "dominant_7th": dominant_7th is not True, 1819 "gravity": gravity != 1.0, 1820 "nir_strength": nir_strength != 0.5, 1821 "minor_turnaround_weight": minor_turnaround_weight != 0.0, 1822 "root_diversity": root_diversity != subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1823 } 1824 passed = [name for name, was_set in generation_only.items() if was_set] 1825 1826 if passed: 1827 raise ValueError( 1828 f"{', '.join(sorted(passed))} only apply when generating with style=. " 1829 "A concrete progression takes these as methods instead — e.g. " 1830 ".cadence('strong') for the close, and the key binds at " 1831 "composition.harmony() / resolve() time." 1832 ) 1833 1834 if isinstance(source, Progression): 1835 return source 1836 1837 if isinstance(source, str): 1838 if source in _PRESETS: 1839 return progression(_PRESETS[source], beats=beats) 1840 known = ", ".join(sorted(_PRESETS)) 1841 raise ValueError( 1842 f"Unknown progression preset {source!r}. Known presets: {known}. " 1843 "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])." 1844 ) 1845 1846 if source is None: 1847 raise ValueError("progression() needs a source list (or style=...)") 1848 1849 elements = list(source) 1850 1851 if not elements: 1852 raise ValueError("progression list is empty — pass at least one chord") 1853 1854 lengths = _span_lengths(beats, len(elements)) 1855 1856 return Progression(spans = tuple( 1857 parse_element(element, beats=lengths[index]) 1858 for index, element in enumerate(elements) 1859 ))
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])
3130def sieve ( 3131 classes: typing.Sequence[typing.Tuple[int, int]], 3132 hi: int, 3133 lo: int = 0, 3134) -> typing.List[int]: 3135 3136 """Xenakis sieve: the sorted integers in ``[lo, hi)`` in any of the classes. 3137 3138 A sieve (Xenakis's *crible*) is a logical formula over **residual 3139 classes** that denotes a subset of the integers. This primary form takes 3140 a list of ``(modulus, residue)`` pairs and returns their **union** over a 3141 bounded range — every ``x`` in ``[lo, hi)`` with ``x % modulus == residue`` 3142 for at least one class. The integers index *any* ordered parameter, so 3143 one kernel builds custom scales (over 0–11 semitones), non-octave pitch 3144 pools, rhythm grids, and bar-selection masks. 3145 3146 For intersection and complement, compose :func:`residual_class` objects 3147 with ``&``, ``|``, ``~`` and evaluate the result (see :class:`Sieve`). 3148 3149 Parameters: 3150 classes: ``(modulus, residue)`` pairs. ``modulus`` must be ≥ 1; the 3151 residue is taken modulo the modulus. 3152 hi: Exclusive upper bound. 3153 lo: Inclusive lower bound (default 0). 3154 3155 Returns: 3156 The sorted, de-duplicated integers in range that satisfy any class. 3157 3158 Raises: 3159 ValueError: If a modulus is below 1. 3160 3161 Example: 3162 ```python 3163 sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) 3164 # → [0, 2, 4, 5, 7, 9, 11] — the major scale as a sieve 3165 sieve([(2, 0)], hi=12) # → [0, 2, 4, 6, 8, 10] — whole-tone 3166 sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pitch pool 3167 ``` 3168 """ 3169 3170 for modulus, _residue in classes: 3171 if modulus < 1: 3172 raise ValueError(f"sieve modulus must be at least 1 — got {modulus}") 3173 3174 hits = { 3175 x 3176 for x in range(lo, hi) 3177 for modulus, residue in classes 3178 if x % modulus == residue % modulus 3179 } 3180 3181 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
3241def residual_class (modulus: int, residue: int) -> Sieve: 3242 3243 """A single residual class ``{x : x % modulus == residue}`` as a :class:`Sieve`. 3244 3245 The atom of sieve algebra (Xenakis's notation ``modulus @ residue``). 3246 Combine with ``&`` ``|`` ``~`` and call :meth:`Sieve.evaluate`. 3247 """ 3248 3249 if modulus < 1: 3250 raise ValueError(f"residual-class modulus must be at least 1 — got {modulus}") 3251 3252 reduced = residue % modulus 3253 3254 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().
100@dataclasses.dataclass (frozen=True) 101class PlacedNote: 102 103 """ 104 One note read back off a pattern being built — see ``PatternBuilder.placed()``. 105 106 A read-only copy rather than a view: a consumer diffing what a generator 107 added must not be able to reach through the answer and edit the pattern. 108 Frozen also makes it hashable, so ``set(after) - set(before)`` works. 109 110 Positions and durations are in **pulses**, the unit the pattern stores and 111 the one every grid classification already uses (``PatternBuilder.thin()`` 112 documents that zone arithmetic). Handing back beats would mean a float 113 divide and a rounding rule that could disagree with the caller's on a note 114 groove has nudged; a caller wanting beats divides by a constant and loses 115 nothing. 116 117 ``duration`` is None for a drone, which has no end until a later 118 ``drone_off()`` places one. 119 120 ``index`` exists so two notes that are otherwise identical stay distinct: 121 nothing stops a hand-placed kick and a generated one landing on the same 122 pulse, and without it a set difference would report the second as already 123 present. It is an identity token, not a count — treat it as opaque. 124 """ 125 126 position: int # Pulse position within the pattern 127 pitch: int # Resolved MIDI note number 128 origin: typing.Optional[str] # Original drum-name string (same contract as Note.origin), None for numeric pitches 129 index: int # Distinguishes notes sharing a position and pitch; opaque, stable only within one build 130 velocity: int 131 duration: typing.Optional[int] # Pulses, or None for a drone (a raw Note On with no end) 132 primary_unmapped: bool = False # True when this pitch is a placeholder that the primary device will not sound (see Note.primary_unmapped)
One note read back off a pattern being built — see PatternBuilder.placed().
A read-only copy rather than a view: a consumer diffing what a generator
added must not be able to reach through the answer and edit the pattern.
Frozen also makes it hashable, so set(after) - set(before) works.
Positions and durations are in pulses, the unit the pattern stores and
the one every grid classification already uses (PatternBuilder.thin()
documents that zone arithmetic). Handing back beats would mean a float
divide and a rounding rule that could disagree with the caller's on a note
groove has nudged; a caller wanting beats divides by a constant and loses
nothing.
duration is None for a drone, which has no end until a later
drone_off() places one.
index exists so two notes that are otherwise identical stay distinct:
nothing stops a hand-placed kick and a generated one landing on the same
pulse, and without it a set difference would report the second as already
present. It is an identity token, not a count — treat it as opaque.
555def generators () -> typing.List[typing.Dict[str, typing.Any]]: 556 557 """Describe every generator Subsequence offers, as plain data. 558 559 The whole catalogue in one call, so a caller never has to hold its own list 560 of what exists. Each entry is exactly what :func:`describe_generator` 561 returns for that name. 562 563 Example: 564 ```python 565 import subsequence 566 567 for shape in subsequence.generators(): 568 print(shape["name"], len(shape["parameters"])) 569 ``` 570 """ 571 572 return [describe_generator(name) for name in GENERATORS]
Describe every generator Subsequence offers, as plain data.
The whole catalogue in one call, so a caller never has to hold its own list
of what exists. Each entry is exactly what describe_generator()
returns for that name.
Example:
import subsequence for shape in subsequence.generators(): print(shape["name"], len(shape["parameters"]))
471def describe_generator (name: str) -> typing.Dict[str, typing.Any]: 472 473 """Describe one generator's parameters as plain data. 474 475 Parameters: 476 name: The generator's method name on ``PatternBuilder``, e.g. 477 ``"ghost_fill"``. 478 479 Returns: 480 A dict with ``name``, ``summary``, ``partial``, ``parameters`` and 481 ``dropped`` — see this module's contract. Each parameter says whether 482 it is ``required`` and what it defaults to. 483 484 Raises: 485 ValueError: if *name* is not a declared generator. A transform is 486 named as such rather than reported missing, since the two 487 catalogues are easy to confuse and the answer is one call away. 488 489 Example: 490 ```python 491 import subsequence 492 493 shape = subsequence.describe_generator("euclidean") 494 shape["parameters"][0]["kind"] # 'pitch' 495 ``` 496 """ 497 498 if name not in GENERATORS: 499 500 if name in TRANSFORMS: 501 raise ValueError( 502 f"{name!r} is a transform, not a generator — " 503 f"use subsequence.describe_transform({name!r})." 504 ) 505 506 raise ValueError( 507 f"{name!r} is not a declared generator. " 508 f"Use subsequence.generators() to see the {len(GENERATORS)} available." 509 ) 510 511 return _describe(name)
Describe one generator's parameters as plain data.
Arguments:
- name: The generator's method name on
PatternBuilder, e.g."ghost_fill".
Returns:
A dict with
name,summary,partial,parametersanddropped— see this module's contract. Each parameter says whether it isrequiredand what it defaults to.
Raises:
- ValueError: if name is not a declared generator. A transform is named as such rather than reported missing, since the two catalogues are easy to confuse and the answer is one call away.
Example:
import subsequence shape = subsequence.describe_generator("euclidean") shape["parameters"][0]["kind"] # 'pitch'
575def transforms () -> typing.List[typing.Dict[str, typing.Any]]: 576 577 """Describe every transform Subsequence offers, as plain data. 578 579 The companion to :func:`generators`: those *place* notes, these *reshape* 580 notes already placed. A surface that wants to roll a rhythm off the 581 downbeat asks here rather than holding its own list of method names. 582 583 Example: 584 ```python 585 import subsequence 586 587 for shape in subsequence.transforms(): 588 print(shape["name"], len(shape["parameters"])) 589 ``` 590 """ 591 592 return [_describe(name) for name in TRANSFORMS]
Describe every transform Subsequence offers, as plain data.
The companion to generators(): those place notes, these reshape
notes already placed. A surface that wants to roll a rhythm off the
downbeat asks here rather than holding its own list of method names.
Example:
import subsequence for shape in subsequence.transforms(): print(shape["name"], len(shape["parameters"]))
514def describe_transform (name: str) -> typing.Dict[str, typing.Any]: 515 516 """Describe one transform's parameters as plain data. 517 518 Parameters: 519 name: The transform's method name on ``PatternBuilder``, e.g. 520 ``"rotate"``. 521 522 Returns: 523 The same shape :func:`describe_generator` returns. A transform is 524 applied the same way a generator is, so a caller that can drive one 525 can drive the other without a second code path. 526 527 Raises: 528 ValueError: if *name* is not a declared transform. 529 530 Example: 531 ```python 532 import subsequence 533 534 shape = subsequence.describe_transform("rotate") 535 shape["parameters"][0]["name"] # 'steps' 536 ``` 537 """ 538 539 if name not in TRANSFORMS: 540 541 if name in GENERATORS: 542 raise ValueError( 543 f"{name!r} is a generator, not a transform — " 544 f"use subsequence.describe_generator({name!r})." 545 ) 546 547 raise ValueError( 548 f"{name!r} is not a declared transform. " 549 f"Use subsequence.transforms() to see the {len(TRANSFORMS)} available." 550 ) 551 552 return _describe(name)
Describe one transform's parameters as plain data.
Arguments:
- name: The transform's method name on
PatternBuilder, e.g."rotate".
Returns:
The same shape
describe_generator()returns. A transform is applied the same way a generator is, so a caller that can drive one can drive the other without a second code path.
Raises:
- ValueError: if name is not a declared transform.
Example:
import subsequence shape = subsequence.describe_transform("rotate") shape["parameters"][0]["name"] # 'steps'