subsequence
Subsequence - an algorithmic composition framework for Python.
Subsequence gives you a palette of mathematical building blocks - Euclidean rhythms, cellular automata, L-systems, Markov chains, cognitive melody generation - and a stateful engine that lets them interact and evolve over time. Unlike tools that loop a fixed pattern forever, Subsequence rebuilds every pattern fresh before each cycle with full context, so algorithms feed into each other and compositions emerge that no single technique could produce alone. It generates pure MIDI (no audio engine) to control hardware synths, modular systems, drum machines, or software VSTs/DAWs.
What makes it different:
- A rich algorithmic palette. Euclidean and Bresenham rhythm generators, cellular automata (1D and 2D), L-system string rewriting, Markov chains, cognitive melody via the Narmour model, probability- weighted ghost notes, position-aware thinning, drones and continuous notes, Perlin and pink noise, logistic chaos maps - plus groove templates, velocity shaping, and pitch-bend automation to shape how they sound.
- Stateful patterns that evolve. Each pattern is a Python function rebuilt fresh every cycle with full context - current chord, section, cycle count, shared data from other patterns. A Euclidean rhythm can thin itself as tension builds, a cellular automaton can seed from the harmony, and a Markov chain can shift behaviour between sections.
- Optional chord graph. Define weighted chord and key transitions via probability graphs, with gravity and automatic voice leading. Eleven built-in palettes and frozen progressions to lock some sections while others evolve freely. Layer on cognitive harmony for Narmour-based melodic inertia.
- Sub-microsecond clock. Hybrid sleep+spin timing achieves typical pulse jitter of < 5 us on Linux, with zero long-term drift.
- Turn anything into music.
composition.schedule()runs any Python function on a beat cycle - APIs, sensors, files. Anything Python can reach becomes a musical parameter. - Pure MIDI, zero sound engine. No audio synthesis, no heavyweight dependencies. Route to hardware synths, drum machines, Eurorack, or software instruments.
Composition tools:
- Rhythm and feel. Euclidean and Bresenham generators, multi-voice
weighted Bresenham distribution (
bresenham_poly()), ghost note layers (ghost_fill()), position-aware note removal (thin()- the musical inverse 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.fibonacci()), Gray-Scott reaction-diffusion patterns (p.reaction_diffusion()), Lorenz strange-attractor generation (p.lorenz()), exhaustive pitch-subsequence melodies (p.de_bruijn()), step-wise melodies with guaranteed pitch diversity (p.self_avoiding_walk()), drones and explicit note on/off events (p.drone(),p.drone_off(),p.silence()), groove templates (Groove.swing(),Groove.from_agr()), swing 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. - Mini-notation.
p.seq("x x [x x] x", pitch="kick")- concise string syntax for rhythms, subdivisions, and per-step probability. - Scales.
p.snap_to_scale()snaps notes to any scale.scale_notes()generates a list of MIDI note numbers from a key, mode, and range or note count - useful for arpeggios, Markov chains, and melodic walks. Built-in western and non-western modes, 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. - 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, Degree, ChordTone, Approach, MotifEvent, ControlEvent, Progression, ChordSpan, PitchSet, progression, between, parse_chord, register_chord_quality, register_scale, scale_notes, bank_select.
1""" 2Subsequence - an algorithmic composition framework for Python. 3 4Subsequence gives you a palette of mathematical building blocks - 5Euclidean rhythms, cellular automata, L-systems, Markov chains, 6cognitive melody generation - and a stateful engine that lets them 7interact and evolve over time. Unlike tools that loop a fixed pattern 8forever, Subsequence rebuilds every pattern fresh before each cycle 9with full context, so algorithms feed into each other and compositions 10emerge that no single technique could produce alone. It generates pure 11MIDI (no audio engine) to control hardware synths, modular systems, 12drum machines, or software VSTs/DAWs. 13 14What makes it different: 15 16- **A rich algorithmic palette.** Euclidean and Bresenham rhythm 17 generators, cellular automata (1D and 2D), L-system string rewriting, 18 Markov chains, cognitive melody via the Narmour model, probability- 19 weighted ghost notes, position-aware thinning, drones and continuous 20 notes, Perlin and pink noise, logistic chaos maps - plus groove 21 templates, velocity shaping, and pitch-bend automation to shape 22 how they sound. 23- **Stateful patterns that evolve.** Each pattern is a Python function 24 rebuilt fresh every cycle with full context - current chord, section, 25 cycle count, shared data from other patterns. A Euclidean rhythm can 26 thin itself as tension builds, a cellular automaton can seed from the 27 harmony, and a Markov chain can shift behaviour between sections. 28- **Optional chord graph.** Define weighted chord and key transitions 29 via probability graphs, with gravity and automatic voice leading. 30 Eleven built-in palettes and frozen progressions to lock some sections 31 while others evolve freely. Layer on cognitive harmony for 32 Narmour-based melodic inertia. 33- **Sub-microsecond clock.** Hybrid sleep+spin timing achieves typical 34 pulse jitter of < 5 us on Linux, with zero long-term drift. 35- **Turn anything into music.** ``composition.schedule()`` runs any 36 Python function on a beat cycle - APIs, sensors, files. Anything 37 Python can reach becomes a musical parameter. 38- **Pure MIDI, zero sound engine.** No audio synthesis, no heavyweight 39 dependencies. Route to hardware synths, drum machines, Eurorack, or 40 software instruments. 41 42Composition tools: 43 44- **Rhythm and feel.** Euclidean and Bresenham generators, multi-voice 45 weighted Bresenham distribution (``bresenham_poly()``), ghost note 46 layers (``ghost_fill()``), position-aware note removal (``thin()`` - 47 the musical inverse of ``ghost_fill``), evolving cellular-automaton 48 rhythms (``cellular_1d()``, ``cellular_2d()``), smooth Perlin noise (``perlin_1d()``, 49 ``perlin_2d()``, ``perlin_1d_sequence()``, ``perlin_2d_grid()``), 50 deterministic chaos sequences (``logistic_map()``), pink 1/f noise 51 (``pink_noise()``), L-system string rewriting (``p.lsystem()``), 52 Markov-chain generation (``p.markov()``), aperiodic binary rhythms 53 (``p.thue_morse()``), golden-ratio beat placement (``p.fibonacci()``), 54 Gray-Scott reaction-diffusion patterns (``p.reaction_diffusion()``), 55 Lorenz strange-attractor generation (``p.lorenz()``), exhaustive 56 pitch-subsequence melodies (``p.de_bruijn()``), step-wise melodies 57 with guaranteed pitch diversity (``p.self_avoiding_walk()``), drones 58 and explicit note on/off events (``p.drone()``, ``p.drone_off()``, 59 ``p.silence()``), 60 groove templates (``Groove.swing()``, ``Groove.from_agr()``), swing via 61 ``p.swing()`` (a shortcut for ``Groove.swing()``), randomize, 62 velocity shaping and ramps (``p.build_velocity_ramp()``), dropout, per-step 63 probability, and polyrhythms via independent pattern lengths. 64- **Melody generation.** ``p.melody()`` with ``MelodicState`` applies 65 the Narmour Implication-Realization model to single-note lines: 66 continuation after small steps, reversal after large leaps, chord-tone 67 weighting, range gravity, and pitch-diversity penalty. History persists 68 across bar rebuilds for natural phrase continuity. 69- **Chord parts.** ``comp.chords()`` and ``p.progression()`` play a chord 70 progression — generated from a chord-graph style or given explicitly — at a 71 declared *harmonic rhythm*: a fixed length, a shaped ``[WHOLE, HALF, HALF]`` 72 sequence, or ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` for chords of varying, 73 quantized length. Voicing density, ``detached`` articulation, and a seed for 74 a fixed phrase are all declarative. 75- **Expression.** CC messages/ramps, pitch bend, note-correlated 76 bend/portamento/slide, program changes, SysEx, and OSC output - all 77 from within patterns. 78- **Form and structure.** Musical form as a weighted graph, ordered list, 79 or generator. Patterns read ``p.section`` to adapt. Conductor signals 80 (LFOs, ramps) shape intensity over time. 81- **Mini-notation.** ``p.seq("x x [x x] x", pitch="kick")`` - concise 82 string syntax for rhythms, subdivisions, and per-step probability. 83- **Scales.** ``p.snap_to_scale()`` snaps notes to any 84 scale. ``scale_notes()`` generates a list of MIDI note numbers from 85 a key, mode, and range or note count - useful for arpeggios, Markov 86 chains, and melodic walks. Built-in western and non-western modes, 87 plus ``register_scale()`` for your own. 88- **Microtonal tuning.** ``composition.tuning()`` applies a tuning 89 system globally; ``p.apply_tuning()`` overrides per-pattern. 90 Supports Scala ``.scl`` files, explicit cent lists, frequency ratios, 91 and N-TET equal temperaments. Polyphonic parts use explicit channel 92 rotation so simultaneous notes can carry independent pitch bends 93 without MPE. Compatible with any standard MIDI synthesiser. 94- **Randomness tools.** Weighted choice, no-repeat shuffle, random 95 walk, probability gates. Deterministic seeding makes every decision 96 repeatable: set it composition-wide (``seed=42``) or per generator 97 (``seed=`` on any generator, with ``rng=`` for an explicit instance — 98 precedence ``rng`` > ``seed`` > the pattern's ``p.rng``). See the 99 README "Conventions" section for the API's shared vocabulary. 100- **Pattern transforms.** Legato, detached, fixed gate (``p.duration()``), 101 reverse, time-stretch, rotate, transpose, invert, randomize, and 102 conditional ``p.every()``. 103 104Integration: 105 106- **MIDI clock.** Master (``clock_output()``) or follower 107 (``clock_follow=True``). When multiple inputs are connected, only 108 one may be designated as the master clock source; messages from 109 other inputs are filtered to prevent sync interference. Sync to a 110 DAW or drive hardware. 111- **Latency compensation.** Declare each output device's physical 112 latency (``latency_ms=``); Subsequence delays the faster devices so 113 a mix of hardware and slower software instruments sound together. 114- **MIDI mirroring with per-device drum maps.** Fan a pattern out to 115 extra ``(device, channel)`` destinations; an entry can carry its own 116 ``drum_note_map`` so one named drum hit re-resolves to the right voice 117 on each device — a DRM1 and a General MIDI sampler alike. 118- **Hardware control.** CC input mapping from knobs/faders to 119 ``composition.data``; patterns read and write the same dict via 120 ``p.data`` for both external data access and cross-pattern 121 communication. OSC for bidirectional communication with mixers, 122 lighting, visuals. 123- **Live held-note arpeggiator.** ``composition.note_input()`` tracks the 124 notes you hold on a keyboard; a pattern reads them with ``p.held_notes()`` 125 and arpeggiates them (``p.arpeggio(p.held_notes())``), with ``release_ms`` 126 debounce and ``latch``. A performance layer over the deterministic 127 composition - empty when rendering headlessly. 128- **Live coding.** Hot-swap patterns, change tempo, mute/unmute, and 129 tweak parameters during playback via a built-in TCP eval server. 130- **Hotkeys.** Single keystrokes to jump sections, toggle mutes, or 131 fire any action - with optional bar-boundary quantization. 132- **Real-time pattern triggering.** ``composition.trigger()`` generates 133 one-shot patterns in response to sensors, OSC, or any event. 134- **Terminal display.** Live status line (BPM, bar, section, chord). 135 Add ``grid=True`` for an ASCII pattern grid showing velocity and 136 sustain - makes legato, detached, and staccato articulations visually 137 distinct at a glance. 138 Add ``grid_scale=2`` to zoom in horizontally, revealing swing and 139 groove micro-timing. 140- **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to 141 broadcast live composition metadata and visualize piano-roll pattern 142 grids in a reactive HTTP/WebSocket browser dashboard. 143- **Ableton Link.** Industry-standard wireless tempo/phase sync 144 (``comp.link()``; requires ``pip install subsequence[link]``). 145 Any Link-enabled app on the same LAN — Ableton Live, iOS synths, 146 other Subsequence instances — stays in time automatically. 147- **Recording.** Record to standard MIDI file. Render to file without 148 waiting for real-time playback. 149 150Minimal example: 151 152 ```python 153 import subsequence 154 import subsequence.constants.instruments.gm_drums as gm_drums 155 156 comp = subsequence.Composition(bpm=120) 157 158 @comp.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) 159 def drums (p): 160 (p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) 161 .hit_steps("snare_1", [4, 12], velocity=90) 162 .hit_steps("hi_hat_closed", range(16), velocity=70)) 163 164 comp.play() 165 ``` 166 167Community and Feedback: 168 169- **Discussions:** Chat and ask questions at https://github.com/simonholliday/subsequence/discussions 170- **Issues:** Report bugs and request features at https://github.com/simonholliday/subsequence/issues 171 172Package-level exports: ``Composition``, ``Chord``, ``Groove``, ``MelodicState``, ``Tuning``, ``Motif``, ``Phrase``, ``motif``, ``Degree``, ``ChordTone``, ``Approach``, ``MotifEvent``, ``ControlEvent``, ``Progression``, ``ChordSpan``, ``PitchSet``, ``progression``, ``between``, ``parse_chord``, ``register_chord_quality``, ``register_scale``, ``scale_notes``, ``bank_select``. 173""" 174 175import subsequence.chords 176import subsequence.composition 177import subsequence.groove 178import subsequence.harmonic_rhythm 179import subsequence.intervals 180import subsequence.melodic_state 181import subsequence.midi_utils 182import subsequence.motifs 183import subsequence.progressions 184import subsequence.tuning 185 186 187Composition = subsequence.composition.Composition 188Motif = subsequence.motifs.Motif 189Phrase = subsequence.motifs.Phrase 190motif = subsequence.motifs.motif 191Degree = subsequence.motifs.Degree 192ChordTone = subsequence.motifs.ChordTone 193Approach = subsequence.motifs.Approach 194MotifEvent = subsequence.motifs.MotifEvent 195ControlEvent = subsequence.motifs.ControlEvent 196Progression = subsequence.progressions.Progression 197ChordSpan = subsequence.progressions.ChordSpan 198PitchSet = subsequence.progressions.PitchSet 199progression = subsequence.progressions.progression 200Chord = subsequence.chords.Chord 201Groove = subsequence.groove.Groove 202MelodicState = subsequence.melodic_state.MelodicState 203Tuning = subsequence.tuning.Tuning 204between = subsequence.harmonic_rhythm.between 205parse_chord = subsequence.chords.parse_chord 206register_chord_quality = subsequence.chords.register_chord_quality 207register_scale = subsequence.intervals.register_scale 208scale_notes = subsequence.intervals.scale_notes 209bank_select = subsequence.midi_utils.bank_select
1023class Composition: 1024 1025 """ 1026 The top-level controller for a musical piece. 1027 1028 The `Composition` object manages the global clock (Sequencer), the harmonic 1029 progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns. 1030 It serves as the main entry point for defining your music. 1031 1032 Typical workflow: 1033 1. Initialize `Composition` with BPM and Key. 1034 2. Define harmony and form (optional). 1035 3. Register patterns using the `@composition.pattern` decorator. 1036 4. Call `composition.play()` to start the music. 1037 """ 1038 1039 def __init__ ( 1040 self, 1041 output_device: typing.Optional[str] = None, 1042 bpm: float = 120, 1043 time_signature: typing.Tuple[int, int] = (4, 4), 1044 key: typing.Optional[str] = None, 1045 scale: typing.Optional[str] = None, 1046 seed: typing.Optional[int] = None, 1047 record: bool = False, 1048 record_filename: typing.Optional[str] = None, 1049 zero_indexed_channels: bool = False, 1050 latency_ms: float = 0.0 1051 ) -> None: 1052 1053 """ 1054 Initialize a new composition. 1055 1056 Parameters: 1057 output_device: The exact name of the MIDI output port to use, 1058 as reported by ``mido.get_output_names()``. Matching is 1059 strict — the string must equal an entry in that list 1060 verbatim. On Linux/ALSA, names include the client and 1061 port IDs (e.g. 1062 ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the 1063 trailing ``:client:port`` digits are assigned in 1064 connection order and can change between reboots or when 1065 a virtual port is recreated. To look up the current 1066 names:: 1067 1068 import mido 1069 for n in mido.get_output_names(): print(n) 1070 1071 If `None`, Subsequence auto-discovers — uses the only 1072 available device, or prompts to choose if several exist. 1073 bpm: Initial tempo in beats per minute (default 120). 1074 key: The root key of the piece (e.g., "C", "F#", "Bb"). 1075 Required if you plan to use `harmony()`. 1076 scale: The scale/mode of the piece (e.g. "minor", "dorian", 1077 or any registered scale name). Used to resolve scale 1078 degrees in motifs; defaults to major (ionian) when unset. 1079 seed: An optional integer for deterministic randomness. When set, 1080 every random decision (chord choices, drum probability, etc.) 1081 will be identical on every run. 1082 record: When True, record all MIDI events to a file. 1083 record_filename: Optional filename for the recording (defaults to timestamp). 1084 zero_indexed_channels: When False (default), MIDI channels use 1085 1-based numbering (1-16) matching instrument labelling. 1086 Channel 10 is drums, the way musicians and hardware panels 1087 show it. When True, channels use 0-based numbering (0-15) 1088 matching the raw MIDI protocol. 1089 latency_ms: Physical output latency of the primary device in 1090 milliseconds, for delay compensation (default 0.0, must be 1091 non-negative). Set this when the primary output sounds late 1092 (e.g. a software sampler) so Subsequence delays faster 1093 devices to line everything up. See ``midi_output()`` for 1094 additional devices. 1095 1096 Example: 1097 ```python 1098 comp = subsequence.Composition(bpm=128, key="Eb", seed=123) 1099 ``` 1100 """ 1101 1102 if latency_ms < 0: 1103 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 1104 1105 self.output_device = output_device 1106 self.bpm = bpm 1107 self.time_signature = time_signature 1108 self.key = key 1109 self.scale = scale 1110 self._seed: typing.Optional[int] = seed 1111 self._zero_indexed_channels: bool = zero_indexed_channels 1112 self._output_latency_ms: float = latency_ms 1113 1114 # Determinism plumbing: named-stream derivation state. Build-time 1115 # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so 1116 # adding one call never shifts another's stream; play-time pattern 1117 # streams are name-keyed in _build_pattern_from_pending. 1118 self._freeze_count: int = 0 1119 self._harmony_count: int = 0 1120 self._form_count: int = 0 1121 self._reroll_nonces: typing.Dict[str, int] = {} 1122 self._locked_names: typing.Set[str] = set() 1123 1124 self._sequencer = subsequence.sequencer.Sequencer( 1125 output_device_name = output_device, 1126 initial_bpm = bpm, 1127 time_signature = time_signature, 1128 record = record, 1129 record_filename = record_filename 1130 ) 1131 1132 self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None 1133 self._harmony_cycle_beats: typing.Optional[int] = None 1134 self._harmony_reschedule_lookahead: float = 1 1135 self._section_progressions: typing.Dict[str, Progression] = {} 1136 self._bound_progression: typing.Optional[Progression] = None 1137 self._pinned_chords: typing.Dict[int, typing.Any] = {} 1138 self._harmony_horizon = _HarmonyHorizon() 1139 self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {} 1140 self._pending_patterns: typing.List[_PendingPattern] = [] 1141 # Names of patterns declared by the most recent live-reload exec (added by 1142 # pattern()/layer() as they run); the deletion diff in _apply_source_async 1143 # tears down any running pattern absent from this set. 1144 self._declared_names: typing.Set[str] = set() 1145 self._pending_scheduled: typing.List[_PendingScheduled] = [] 1146 self._form_state: typing.Optional[subsequence.form_state.FormState] = None 1147 self._builder_bar: int = 0 1148 self._display: typing.Optional[subsequence.display.Display] = None 1149 self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None 1150 self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None 1151 self._is_live: bool = False 1152 self._running_patterns: typing.Dict[str, typing.Any] = {} 1153 self._input_device: typing.Optional[str] = None 1154 self._input_device_alias: typing.Optional[str] = None 1155 self._clock_follow: bool = False 1156 self._clock_output: bool = False 1157 self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] 1158 self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] 1159 # Held-note input config from note_input() (None = not declared). 1160 self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None 1161 # Additional output devices registered with midi_output() after construction. 1162 self._additional_outputs: typing.List[_AdditionalOutput] = [] 1163 # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) 1164 self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = [] 1165 # Maps alias/name → output device index (populated in _run after all devices are opened). 1166 self._output_device_names: typing.Dict[str, int] = {} 1167 # Maps alias/name → input device index (populated in _run after all input devices are opened). 1168 self._input_device_names: typing.Dict[str, int] = {} 1169 self.data: typing.Dict[str, typing.Any] = {} 1170 self._osc_server: typing.Optional[subsequence.osc.OscServer] = None 1171 self.conductor = subsequence.conductor.Conductor() 1172 self._web_ui_enabled: bool = False 1173 self._web_ui_http_host: str = "127.0.0.1" 1174 self._web_ui_ws_host: str = "127.0.0.1" 1175 self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None 1176 self._link_quantum: typing.Optional[float] = None 1177 1178 # Hotkey state — populated by hotkeys() and hotkey(). 1179 self._hotkeys_enabled: bool = False 1180 self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} 1181 self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] 1182 self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None 1183 1184 # Tuning state — populated by tuning(). 1185 self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning 1186 self._tuning_bend_range: float = 2.0 1187 self._tuning_channels: typing.Optional[typing.List[int]] = None 1188 self._tuning_reference_note: int = 60 1189 self._tuning_exclude_drums: bool = True 1190 1191 def _resolve_device_id (self, device: subsequence.midi_utils.DeviceId) -> int: 1192 """Resolve an output device id (None/int/str) to an integer index. 1193 1194 ``None`` → 0 (primary device). ``int`` → returned as-is. 1195 ``str`` → looked up in ``_output_device_names``; logs a warning and 1196 returns 0 if the name is unknown (called after all devices are opened 1197 in ``_run()``). 1198 """ 1199 if device is None: 1200 return 0 1201 if isinstance(device, int): 1202 return device 1203 idx = self._output_device_names.get(device) 1204 if idx is None: 1205 logger.warning( 1206 f"Unknown output device name '{device}' — routing to device 0. " 1207 f"Available names: {list(self._output_device_names.keys())}" 1208 ) 1209 return 0 1210 return idx 1211 1212 def _resolve_input_device_id (self, device: subsequence.midi_utils.DeviceId) -> typing.Optional[int]: 1213 """Resolve an input device id (None/int/str) to an integer index. 1214 1215 ``None`` → ``None`` (matches any input device — existing behaviour). 1216 ``int`` → returned as-is. ``str`` → looked up in ``_input_device_names``; 1217 logs a warning and returns ``None`` if the name is unknown. 1218 Called after all input devices are opened in ``_run()``. 1219 """ 1220 if device is None: 1221 return None 1222 if isinstance(device, int): 1223 return device 1224 idx = self._input_device_names.get(device) 1225 if idx is None: 1226 logger.warning( 1227 f"Unknown input device name '{device}' — mapping will be ignored. " 1228 f"Available names: {list(self._input_device_names.keys())}" 1229 ) 1230 return None 1231 return idx 1232 1233 def _resolve_pending_devices (self) -> None: 1234 """Resolve name-based device ids on pending patterns now that all output devices are open.""" 1235 for pending in self._pending_patterns: 1236 if isinstance(pending.raw_device, str): 1237 pending.device = self._resolve_device_id(pending.raw_device) 1238 1239 async def _activate_new_pending_patterns (self) -> None: 1240 1241 """Build and schedule any pending patterns whose names are not yet running. 1242 1243 Used by ``LiveReloader._reload_async`` to bring NEW patterns added 1244 in a live reload into rotation mid-flight. Existing patterns 1245 hot-swap via the decorator (their ``_builder_fn`` is replaced in 1246 place); only patterns whose names are not yet in ``_running_patterns`` 1247 need this graduation step. 1248 1249 Newly-scheduled patterns start at the current sequencer pulse — 1250 they'll generate events from now onward, and the next reschedule 1251 will fire at the same offset as their primary cycle. 1252 """ 1253 1254 # Resolve any deferred string-device names against the now-open 1255 # device registry (no-op for int/None devices). 1256 self._resolve_pending_devices() 1257 1258 # Dedupe by name, last declaration wins — re-declaring a pattern in a 1259 # reloaded source must not schedule two copies. 1260 new_by_name: typing.Dict[str, _PendingPattern] = {} 1261 1262 for pending in self._pending_patterns: 1263 if pending.builder_fn.__name__ not in self._running_patterns: 1264 new_by_name[pending.builder_fn.__name__] = pending 1265 1266 new_pending = list(new_by_name.values()) 1267 1268 if not new_pending: 1269 return 1270 1271 current_pulse = self._sequencer.pulse_count 1272 1273 for pending in new_pending: 1274 1275 pattern = self._build_pattern_from_pending(pending, start_pulse = current_pulse) 1276 await self._sequencer.schedule_pattern_repeating(pattern, start_pulse = current_pulse) 1277 self._running_patterns[pending.builder_fn.__name__] = pattern 1278 1279 logger.info(f"Live-reload: scheduled new pattern '{pending.builder_fn.__name__}'") 1280 1281 # Prune graduated (and stale duplicate) declarations: leaving them in 1282 # _pending_patterns resurrected deleted patterns on every later reload. 1283 self._pending_patterns = [ 1284 pending for pending in self._pending_patterns 1285 if pending.builder_fn.__name__ not in self._running_patterns 1286 ] 1287 1288 def _resolve_channel (self, channel: int) -> int: 1289 1290 """ 1291 Convert a user-supplied MIDI channel to the 0-indexed value used internally. 1292 1293 When ``zero_indexed_channels`` is False (default), the channel is 1294 validated as 1-16 and decremented by one. When True (0-indexed), the 1295 channel is validated as 0-15 and returned unchanged. 1296 """ 1297 1298 if self._zero_indexed_channels: 1299 if not 0 <= channel <= 15: 1300 raise ValueError(f"MIDI channel must be 0-15 (zero_indexed_channels=True), got {channel}") 1301 return channel 1302 else: 1303 if not 1 <= channel <= 16: 1304 raise ValueError(f"MIDI channel must be 1-16, got {channel}") 1305 return channel - 1 1306 1307 def _resolve_mirrors ( 1308 self, 1309 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]], 1310 primary: typing.Optional[typing.Tuple[int, int]] = None, 1311 ) -> typing.List[subsequence.pattern.MirrorSpec]: 1312 1313 """ 1314 Validate and normalise a list of mirror destinations. 1315 1316 Each entry is a 2- or 3-element sequence — ``(device_idx, channel)`` or 1317 ``(device_idx, channel, drum_note_map)`` — as a tuple, list, or any such 1318 iterable. ``channel`` is expressed in the user's channel-numbering 1319 convention (1-16 by default, 0-15 when ``zero_indexed_channels=True``); 1320 this method converts it to canonical 0-indexed form and rejects 1321 malformed entries. The optional ``drum_note_map`` is preserved verbatim 1322 so the sequencer can re-resolve mirrored drum names per device. 1323 1324 String device names are NOT supported here; users wanting a named 1325 device should pass the integer index returned from ``midi_output()``. 1326 1327 If ``primary=(device, channel)`` is supplied (canonical 0-indexed 1328 form), a mirror entry whose ``(device, channel)`` matches it triggers a 1329 ``logger.warning`` — this is almost always a user error (every event 1330 would double-fire on the same destination). The optional map is ignored 1331 for this comparison. Skipped when ``primary`` is ``None``, since the 1332 runtime API call site supplies its own check. 1333 """ 1334 1335 if mirrors is None: 1336 return [] 1337 1338 resolved: typing.List[subsequence.pattern.MirrorSpec] = [] 1339 1340 for entry in mirrors: 1341 1342 # Accept any 2- or 3-element iterable (tuple, list, etc.) — config 1343 # files and JSON sources naturally produce lists. Validate shape at 1344 # decoration time so bad inputs surface here instead of producing 1345 # inscrutable failures inside the sequencer. 1346 try: 1347 items = list(entry) 1348 except TypeError: 1349 raise ValueError(f"Mirror entry must be a (device, channel[, drum_note_map]) tuple — got {entry!r}") 1350 1351 if len(items) not in (2, 3): 1352 raise ValueError(f"Mirror entry must have 2 or 3 elements (device, channel[, drum_note_map]) — got {entry!r}") 1353 1354 device = items[0] 1355 channel = items[1] 1356 drum_map = items[2] if len(items) == 3 else None 1357 1358 if not isinstance(device, int) or isinstance(device, bool): 1359 raise ValueError(f"Mirror device must be an integer index — got {type(device).__name__} ({device!r})") 1360 1361 if not isinstance(channel, int) or isinstance(channel, bool): 1362 raise ValueError(f"Mirror channel must be an integer — got {type(channel).__name__} ({channel!r})") 1363 1364 if drum_map is not None and not isinstance(drum_map, dict): 1365 raise ValueError(f"Mirror drum_note_map must be a dict or None — got {type(drum_map).__name__} ({drum_map!r})") 1366 1367 resolved_channel = self._resolve_channel(channel) 1368 1369 if primary is not None and (device, resolved_channel) == primary: 1370 logger.warning( 1371 f"Mirror destination {(device, resolved_channel)} matches the pattern's primary destination " 1372 f"— every event will double-fire on this (device, channel). This is almost " 1373 f"certainly unintended." 1374 ) 1375 1376 resolved_entry: subsequence.pattern.MirrorSpec = ( 1377 (device, resolved_channel) 1378 if drum_map is None 1379 else (device, resolved_channel, drum_map) 1380 ) 1381 resolved.append(resolved_entry) 1382 1383 return resolved 1384 1385 @property 1386 def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: 1387 """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" 1388 return self._harmonic_state 1389 1390 def current_chord (self) -> typing.Optional[typing.Any]: 1391 1392 """The chord sounding at the playhead, or ``None`` without harmony. 1393 1394 Reads the harmony window at the current pulse, so it stays accurate 1395 under variable harmonic rhythm and clock lookahead (the engine's 1396 ``current_chord`` flips *lookahead* beats early — this does not). 1397 Falls back to the engine's chord before playback starts. The chord 1398 may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span 1399 is spiced; it duck-types the ``Chord`` voicing protocol either way. 1400 """ 1401 1402 if not self._harmony_horizon.is_empty: 1403 beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat 1404 chord = self._harmony_horizon.chord_at(beat) 1405 if chord is not None: 1406 return chord 1407 1408 if self._harmonic_state is not None: 1409 return self._harmonic_state.get_current_chord() 1410 1411 return None 1412 1413 @property 1414 def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]: 1415 """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" 1416 return self._form_state 1417 1418 @property 1419 def sequencer (self) -> subsequence.sequencer.Sequencer: 1420 """The underlying ``Sequencer`` instance.""" 1421 return self._sequencer 1422 1423 @property 1424 def running_patterns (self) -> typing.Dict[str, typing.Any]: 1425 """The currently active patterns, keyed by name.""" 1426 return self._running_patterns 1427 1428 @property 1429 def builder_bar (self) -> int: 1430 """Current bar index used by pattern builders.""" 1431 return self._builder_bar 1432 1433 def _require_harmonic_state (self) -> subsequence.harmonic_state.HarmonicState: 1434 """Return the active HarmonicState, raising ValueError if none is configured.""" 1435 if self._harmonic_state is None: 1436 raise ValueError( 1437 "harmony() must be called before this action — " 1438 "no harmonic state has been configured." 1439 ) 1440 return self._harmonic_state 1441 1442 def _coerce_progression (self, source: typing.Any, what: str) -> Progression: 1443 1444 """Coerce a Progression / element list / preset name and resolve it against the key. 1445 1446 Binding freezes one realisation (the value type's identity), so 1447 key-relative content resolves here, at bind time, against the 1448 composition's key and scale. 1449 """ 1450 1451 value = source if isinstance(source, Progression) else subsequence.progressions.progression(source) 1452 1453 if not value.is_concrete: 1454 if self.key is None: 1455 raise ValueError( 1456 f"{what} contains key-relative chords (degrees/romans) — " 1457 "set key= on the Composition so they can resolve" 1458 ) 1459 value = value.resolve(self.key, self.scale or "ionian") 1460 1461 return value 1462 1463 def harmony ( 1464 self, 1465 style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None, 1466 cycle_beats: int = 4, 1467 dominant_7th: bool = True, 1468 gravity: float = 1.0, 1469 nir_strength: float = 0.5, 1470 minor_turnaround_weight: float = 0.0, 1471 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1472 reschedule_lookahead: float = 1, 1473 progression: typing.Optional[typing.Any] = None, 1474 ) -> None: 1475 1476 """ 1477 Configure the harmonic logic and chord change intervals. 1478 1479 Two sources, combinable: a **bound progression** (``progression=`` — a 1480 :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, 1481 or chord names) walked span by span on the global clock; and/or a 1482 **graph style** stepping live chords. With only a progression bound, 1483 it loops on exhaustion; with a style configured too, exhaustion falls 1484 through to live stepping (the frozen-replay bridge). Calling with 1485 neither argument keeps today's default live engine 1486 (``style="functional_major"``). 1487 1488 Parameters: 1489 style: The harmonic style to use. Built-in: "functional_major" 1490 (alias "diatonic_major"), "turnaround", "aeolian_minor", 1491 "phrygian_minor", "lydian_major", "dorian_minor", 1492 "chromatic_mediant", "suspended", "mixolydian", "whole_tone", 1493 "diminished". See README for full descriptions. 1494 cycle_beats: How many beats each live chord lasts (default 4). 1495 Bound progressions carry their own harmonic rhythm in their 1496 spans, so this applies to live stepping only. 1497 dominant_7th: Whether to include V7 chords (default True). 1498 gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. 1499 nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement 1500 expectations. 1501 minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. 1502 root_diversity: Root-repetition damping (0.0 to 1.0). Each recent 1503 chord sharing a candidate's root reduces the weight to 40% at 1504 the default (0.4). Set to 1.0 to disable. 1505 reschedule_lookahead: How many beats in advance to calculate the 1506 next chord. 1507 progression: A progression to bind to the global clock. Key- 1508 relative content resolves now, against the composition key 1509 and scale (binding freezes one realisation). 1510 1511 Example: 1512 ```python 1513 # A moody minor progression that changes every 8 beats 1514 comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) 1515 1516 # Manual harmony driving everything — loops forever 1517 comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) 1518 ``` 1519 """ 1520 1521 if style is None and progression is None: 1522 style = "functional_major" 1523 1524 if style is not None: 1525 1526 if self.key is None: 1527 raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor") 1528 1529 preserved_history: typing.List[subsequence.chords.Chord] = [] 1530 preserved_current: typing.Optional[subsequence.chords.Chord] = None 1531 1532 if self._harmonic_state is not None: 1533 preserved_history = self._harmonic_state.history.copy() 1534 preserved_current = self._harmonic_state.current_chord 1535 1536 # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call 1537 # gets its own deterministic stream while history and current chord 1538 # are preserved above, and adding a re-call never shifts any other 1539 # consumer's stream. 1540 self._harmony_count += 1 1541 1542 self._harmonic_state = subsequence.harmonic_state.HarmonicState( 1543 key_name = self.key, 1544 graph_style = style, 1545 include_dominant_7th = dominant_7th, 1546 key_gravity_blend = gravity, 1547 nir_strength = nir_strength, 1548 minor_turnaround_weight = minor_turnaround_weight, 1549 root_diversity = root_diversity, 1550 rng = self._stream(f"harmony:{self._harmony_count}") 1551 ) 1552 1553 if preserved_history: 1554 self._harmonic_state.history = preserved_history 1555 if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current): 1556 self._harmonic_state.current_chord = preserved_current 1557 1558 if progression is not None: 1559 self._bound_progression = self._coerce_progression(progression, "harmony(progression=)") 1560 1561 self._harmony_cycle_beats = cycle_beats 1562 self._harmony_reschedule_lookahead = reschedule_lookahead 1563 1564 # A re-call invalidates whatever the horizon had planned. 1565 self._harmony_horizon.invalidate_future() 1566 1567 def freeze ( 1568 self, 1569 bars: int, 1570 end: typing.Optional[typing.Any] = None, 1571 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1572 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1573 ) -> "Progression": 1574 1575 """Capture a chord progression from the live harmony engine. 1576 1577 Runs the harmony engine forward by *bars* chord changes, records each 1578 chord, and returns it as a :class:`Progression` that can be bound to a 1579 form section with :meth:`section_chords`. 1580 1581 The engine state **advances** — successive ``freeze()`` calls produce a 1582 continuing compositional journey so section progressions feel like parts 1583 of a whole rather than isolated islands. 1584 1585 The hybrid constraints compile into the walk: ``end=`` fixes the last 1586 bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` 1587 excludes chords throughout. Specs follow the progression-element 1588 grammar (ints where diatonic, roman/name strings where chromatic) and 1589 resolve against the composition key and scale. A backward 1590 feasibility pass guarantees satisfiability before any chord is drawn; 1591 the forward walk keeps the engine's real history-dependent weighting. 1592 Bar 1 is always the engine's current chord — the journey continues — 1593 so ``pins={1: ...}`` may only name it redundantly. 1594 1595 Parameters: 1596 bars: Number of chords to capture (one per harmony cycle). 1597 end: The chord at the final bar — ``end="V"`` is the cadential 1598 major dominant in minor. 1599 pins: ``{bar: chord}`` — 1-based fiat positions. 1600 avoid: Chords excluded from the walk. 1601 1602 Returns: 1603 A :class:`Progression` with the captured chords and trailing 1604 history for NIR continuity. 1605 1606 Raises: 1607 ValueError: If :meth:`harmony` has not been called first, or the 1608 constraints are contradictory or unsatisfiable. 1609 1610 Example:: 1611 1612 composition.harmony(style="functional_major", cycle_beats=4) 1613 verse = composition.freeze(8, end="V") # the verse sets up the chorus 1614 chorus = composition.freeze(4) # next 4 chords, continuing on 1615 composition.section_chords("verse", verse) 1616 composition.section_chords("chorus", chorus) 1617 """ 1618 1619 hs = self._require_harmonic_state() 1620 1621 if bars < 1: 1622 raise ValueError("bars must be at least 1") 1623 1624 scale = self.scale or "ionian" 1625 key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc 1626 1627 resolved_pins = { 1628 position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]") 1629 for position, spec in (pins or {}).items() 1630 } 1631 resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None 1632 resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])] 1633 1634 if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: 1635 raise ValueError( 1636 f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " 1637 f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " 1638 "pin a later bar, or use pin_chord() for playback fiat" 1639 ) 1640 1641 # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws 1642 # are independent of every other consumer, so frozen progressions are 1643 # reproducible WITHOUT play() and adding a call cannot shift a 1644 # neighbour's output. Engine state still advances normally — chord 1645 # continuity comes from current_chord/history, randomness from the 1646 # salted stream (swap-and-restore keeps hs.rng for play untouched). 1647 self._freeze_count += 1 1648 stream = self._stream(f"freeze:{self._freeze_count}") 1649 saved_rng = hs.rng 1650 1651 if stream is not None: 1652 hs.rng = stream 1653 1654 try: 1655 # The kernel with the engine's own hooks is draw-for-draw the old 1656 # step() loop when unconstrained — one walk path for both. 1657 def _commit (chosen: subsequence.chords.Chord) -> None: 1658 hs.current_chord = chosen 1659 1660 collected = subsequence.sequence_utils.constrained_walk( 1661 hs.graph, 1662 hs.current_chord, 1663 bars, 1664 rng = hs.rng, 1665 pins = resolved_pins, 1666 end = resolved_end, 1667 avoid = resolved_avoid, 1668 weight_modifier = hs._transition_weight, 1669 before_choice = hs._record_transition_source, 1670 after_choice = _commit, 1671 ) 1672 1673 # Advance past the last captured chord so the next freeze() call or 1674 # live playback does not duplicate it. 1675 hs.step() 1676 1677 finally: 1678 hs.rng = saved_rng 1679 1680 span_beats = float(self._harmony_cycle_beats or 4) 1681 1682 return Progression( 1683 spans = tuple( 1684 subsequence.progressions.ChordSpan(chord = chord, beats = span_beats) 1685 for chord in collected 1686 ), 1687 trailing_history = tuple(hs.history), 1688 ) 1689 1690 def section_chords (self, section_name: str, progression: typing.Any) -> None: 1691 1692 """Bind a :class:`Progression` to a named form section. 1693 1694 Every time *section_name* plays, the harmonic clock walks the 1695 progression's spans instead of calling the live engine. Sections 1696 without a bound progression continue generating live chords. 1697 1698 Accepts a :class:`Progression` value (from :meth:`freeze`, the 1699 ``progression()`` factory, or hand-built) or anything the factory 1700 accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord 1701 names. Key-relative content resolves now, against the composition 1702 key and scale. 1703 1704 On exhaustion mid-section the progression loops when no graph style 1705 is configured (and always when it contains a 1706 :class:`~subsequence.progressions.PitchSet`); with a live engine, 1707 exhaustion falls through to live stepping until the section changes. 1708 1709 Parameters: 1710 section_name: Name of the section as defined in :meth:`form`. 1711 progression: The progression to bind. 1712 1713 Raises: 1714 ValueError: If a graph-based form has been configured and 1715 *section_name* is not one of its sections. List and generator 1716 forms yield names lazily, so they cannot be validated here. 1717 1718 Example:: 1719 1720 composition.section_chords("verse", verse_progression) 1721 composition.section_chords("chorus", [1, 6, 3, 7]) 1722 # "bridge" is not bound — it generates live chords 1723 """ 1724 1725 if ( 1726 self._form_state is not None 1727 and self._form_state._section_bars is not None 1728 and section_name not in self._form_state._section_bars 1729 ): 1730 known = ", ".join(sorted(self._form_state._section_bars)) 1731 raise ValueError( 1732 f"Section '{section_name}' not found in form. " 1733 f"Known sections: {known}" 1734 ) 1735 1736 self._section_progressions[section_name] = self._coerce_progression( 1737 progression, f"section_chords({section_name!r})" 1738 ) 1739 self._harmony_horizon.invalidate_future() 1740 1741 def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None: 1742 1743 """Force the chord sounding at a bar — fiat over live generation. 1744 1745 Whatever the harmonic source (live walk, bound progression, section 1746 progression) produces for *bar*, the pinned chord overrides it. 1747 Pass ``None`` to remove a pin. 1748 1749 Parameters: 1750 bar: 1-based bar number (the musician count). 1751 chord: A chord name, int degree, roman string, ``Chord``, 1752 ``PitchSet``, or ``None`` to unpin. Key-relative specs 1753 resolve now, against the composition key and scale. 1754 1755 Example:: 1756 1757 composition.pin_chord(8, "E7") # the turnaround lands on E7 1758 composition.pin_chord(8, None) # let it walk again 1759 """ 1760 1761 if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 1762 raise ValueError(f"bars are 1-based ints, got {bar!r}") 1763 1764 if chord is None: 1765 self._pinned_chords.pop(bar, None) 1766 else: 1767 span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0])) 1768 1769 if not span.is_concrete: 1770 if self.key is None: 1771 raise ValueError("pin_chord with a key-relative spec needs key= on the Composition") 1772 span = span.resolve(subsequence.chords.key_name_to_pc(self.key), self.scale or "ionian") 1773 1774 self._pinned_chords[bar] = _span_chord(span) 1775 1776 self._harmony_horizon.invalidate_future() 1777 1778 def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None: 1779 1780 """Bind a Motif or Phrase to a named form section (per optional part). 1781 1782 Patterns read the binding back with ``p.section_motif(part)`` (or use 1783 the one-call :meth:`phrase_part`); a section with no binding for the 1784 part is silent for that part — bind material or don't, no fallback 1785 guessing. Re-binding is idempotent, so the call is safe in a live 1786 file: re-executing on save is the desired rebind. 1787 1788 Parameters: 1789 section_name: Name of the section as defined in :meth:`form`. 1790 value: A ``Motif`` or ``Phrase`` (anything exposing 1791 ``.length``/``.slice`` places). 1792 part: Optional part label, so one section can carry several 1793 bindings (``"lead"``, ``"bass"``, ...). 1794 1795 Raises: 1796 ValueError: If a graph-based form has been configured and 1797 *section_name* is not one of its sections. 1798 1799 Example:: 1800 1801 composition.section_motifs("verse", verse_line, part="lead") 1802 composition.section_motifs("chorus", chorus_line, part="lead") 1803 """ 1804 1805 if not hasattr(value, "length") or not hasattr(value, "slice"): 1806 raise TypeError( 1807 f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" 1808 ) 1809 1810 if ( 1811 self._form_state is not None 1812 and self._form_state._section_bars is not None 1813 and section_name not in self._form_state._section_bars 1814 ): 1815 known = ", ".join(sorted(self._form_state._section_bars)) 1816 raise ValueError( 1817 f"Section '{section_name}' not found in form. " 1818 f"Known sections: {known}" 1819 ) 1820 1821 self._section_motifs[(section_name, part)] = value 1822 1823 def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: 1824 1825 """ 1826 Register a callback for a sequencer event (e.g., "bar", "start", "stop"). 1827 """ 1828 1829 self._sequencer.on_event(event_name, callback) 1830 1831 1832 # ----------------------------------------------------------------------- 1833 # Hotkey API 1834 # ----------------------------------------------------------------------- 1835 1836 def hotkeys (self, enabled: bool = True) -> None: 1837 1838 """Enable or disable the global hotkey listener. 1839 1840 Must be called **before** :meth:`play` to take effect. When enabled, a 1841 background thread reads single keystrokes from stdin without requiring 1842 Enter. The ``?`` key is always reserved and lists all active bindings. 1843 1844 Hotkeys have zero impact on playback when disabled — the listener 1845 thread is never started. 1846 1847 Args: 1848 enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. 1849 1850 Example:: 1851 1852 composition.hotkeys() 1853 composition.hotkey("a", lambda: composition.form_jump("chorus")) 1854 composition.play() 1855 """ 1856 1857 self._hotkeys_enabled = enabled 1858 1859 1860 def hotkey ( 1861 self, 1862 key: str, 1863 action: typing.Callable[[], None], 1864 quantize: int = 0, 1865 label: typing.Optional[str] = None, 1866 ) -> None: 1867 1868 """Register a single-key shortcut that fires during playback. 1869 1870 The listener must be enabled first with :meth:`hotkeys`. 1871 1872 Most actions — form jumps, ``composition.data`` writes, and 1873 :meth:`tweak` calls — should use ``quantize=0`` (the default). Their 1874 musical effect is naturally delayed to the next pattern rebuild cycle, 1875 which provides automatic musical quantization without extra configuration. 1876 1877 Use ``quantize=N`` for actions where you want an explicit bar-boundary 1878 guarantee, such as :meth:`mute` / :meth:`unmute`. 1879 1880 The ``?`` key is reserved and cannot be overridden. 1881 1882 Args: 1883 key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). 1884 action: Zero-argument callable to execute. 1885 quantize: ``0`` = execute immediately (default). ``N`` = execute 1886 on the next global bar number divisible by *N*. 1887 label: Display name for the ``?`` help listing. Auto-derived from 1888 the function name or lambda body if omitted. 1889 1890 Raises: 1891 ValueError: If ``key`` is the reserved ``?`` character, or if 1892 ``key`` is not exactly one character. 1893 1894 Example:: 1895 1896 composition.hotkeys() 1897 1898 # Immediate — musical effect happens at next pattern rebuild 1899 composition.hotkey("a", lambda: composition.form_jump("chorus")) 1900 composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) 1901 1902 # Explicit 4-bar phrase boundary 1903 composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) 1904 1905 # Named function — label is derived automatically 1906 def drop_to_breakdown (): 1907 composition.form_jump("breakdown") 1908 composition.mute("lead") 1909 1910 composition.hotkey("d", drop_to_breakdown) 1911 1912 composition.play() 1913 """ 1914 1915 if len(key) != 1: 1916 raise ValueError(f"hotkey key must be a single character, got {key!r}") 1917 1918 if key == _HOTKEY_RESERVED: 1919 raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.") 1920 1921 derived = label if label is not None else _derive_label(action) 1922 1923 self._hotkey_bindings[key] = HotkeyBinding( 1924 key = key, 1925 action = action, 1926 quantize = quantize, 1927 label = derived, 1928 ) 1929 1930 1931 def form_jump (self, section_name: str) -> None: 1932 1933 """Jump the form to a named section immediately. 1934 1935 Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the 1936 composition uses graph-mode form (a dict passed to :meth:`form`). 1937 1938 The musical effect is heard at the *next pattern rebuild cycle* — already- 1939 queued MIDI notes are unaffected. This natural delay means ``form_jump`` 1940 is effective without needing explicit quantization. 1941 1942 Args: 1943 section_name: The section to jump to. 1944 1945 Raises: 1946 ValueError: If no form is configured, or the form is not in graph 1947 mode, or *section_name* is unknown. 1948 1949 Example:: 1950 1951 composition.hotkey("c", lambda: composition.form_jump("chorus")) 1952 """ 1953 1954 if self._form_state is None: 1955 raise ValueError("form_jump() requires a form to be configured via composition.form().") 1956 1957 self._form_state.jump_to(section_name) 1958 1959 # The harmony horizon planned against the old section — revoke it. 1960 self._harmony_horizon.invalidate_future() 1961 1962 1963 def form_next (self, section_name: str) -> None: 1964 1965 """Queue the next section — takes effect when the current section ends. 1966 1967 Unlike :meth:`form_jump`, this does not interrupt the current section. 1968 The queued section replaces the automatically pre-decided next section 1969 and takes effect at the natural section boundary. The performer can 1970 change their mind by calling ``form_next`` again before the boundary. 1971 1972 Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the 1973 composition uses graph-mode form (a dict passed to :meth:`form`). 1974 1975 Args: 1976 section_name: The section to queue. 1977 1978 Raises: 1979 ValueError: If no form is configured, or the form is not in graph 1980 mode, or *section_name* is unknown. 1981 1982 Example:: 1983 1984 composition.hotkey("c", lambda: composition.form_next("chorus")) 1985 """ 1986 1987 if self._form_state is None: 1988 raise ValueError("form_next() requires a form to be configured via composition.form().") 1989 1990 self._form_state.queue_next(section_name) 1991 1992 # The harmony horizon planned against the old continuation — revoke it. 1993 self._harmony_horizon.invalidate_future() 1994 1995 1996 def _list_hotkeys (self) -> None: 1997 1998 """Log all active hotkey bindings (triggered by the ``?`` key). 1999 2000 Output appears via the standard logger so it scrolls cleanly above 2001 the :class:`~subsequence.display.Display` status line. 2002 """ 2003 2004 lines = ["Active hotkeys:"] 2005 for key in sorted(self._hotkey_bindings): 2006 b = self._hotkey_bindings[key] 2007 quant_str = "immediate" if b.quantize == 0 else f"quantize={b.quantize}" 2008 lines.append(f" {key} \u2192 {b.label} ({quant_str})") 2009 lines.append(f" ? \u2192 list hotkeys") 2010 logger.info("\n".join(lines)) 2011 2012 2013 def _process_hotkeys (self, bar: int) -> None: 2014 2015 """Drain pending keystrokes and execute due actions. 2016 2017 Called on every ``"bar"`` event by the sequencer when hotkeys are 2018 enabled. Handles both immediate (``quantize=0``) and quantized actions. 2019 2020 Both kinds run here, on the bar-event callback (the event loop): the 2021 keystroke listener thread only enqueues keypresses (``drain()``), it 2022 never executes actions. Immediate (``quantize=0``) bindings fire as soon 2023 as the key is drained; quantized ones wait for their next boundary. 2024 2025 Args: 2026 bar: The current global bar number from the sequencer. 2027 """ 2028 2029 if self._keystroke_listener is None: 2030 return 2031 2032 # Process newly arrived keys. 2033 for key in self._keystroke_listener.drain(): 2034 2035 if key == _HOTKEY_RESERVED: 2036 self._list_hotkeys() 2037 continue 2038 2039 binding = self._hotkey_bindings.get(key) 2040 if binding is None: 2041 continue 2042 2043 if binding.quantize == 0: 2044 # Immediate — execute now (we're on the bar-event callback, 2045 # which is safe for all mutation methods). 2046 try: 2047 binding.action() 2048 logger.info(f"Hotkey '{key}' \u2192 {binding.label}") 2049 except Exception as exc: 2050 logger.warning(f"Hotkey '{key}' action raised: {exc}") 2051 else: 2052 # Defer until the next quantize boundary. 2053 self._pending_hotkey_actions.append( 2054 _PendingHotkeyAction(binding=binding) 2055 ) 2056 2057 # Fire any pending actions whose bar boundary has arrived. 2058 still_pending: typing.List[_PendingHotkeyAction] = [] 2059 2060 for pending in self._pending_hotkey_actions: 2061 if bar % pending.binding.quantize == 0: 2062 try: 2063 pending.binding.action() 2064 logger.info( 2065 f"Hotkey '{pending.binding.key}' \u2192 {pending.binding.label} " 2066 f"(bar {bar})" 2067 ) 2068 except Exception as exc: 2069 logger.warning( 2070 f"Hotkey '{pending.binding.key}' action raised: {exc}" 2071 ) 2072 else: 2073 still_pending.append(pending) 2074 2075 self._pending_hotkey_actions = still_pending 2076 2077 @property 2078 def seed (self) -> typing.Optional[int]: 2079 2080 """ 2081 The composition's random seed, or None when unseeded. 2082 2083 When set, every random decision derives deterministically from this 2084 value through named streams (see ``seed_for()``), so the same script 2085 produces the same music on every run. Assign to set it:: 2086 2087 comp.seed = 42 2088 2089 (Formerly the method ``comp.seed(42)`` — the call form is a hard 2090 break per the pre-1.0 rename policy.) 2091 """ 2092 2093 return self._seed 2094 2095 @seed.setter 2096 def seed (self, value: typing.Optional[int]) -> None: 2097 2098 """Set the composition seed (``comp.seed = 42``).""" 2099 2100 self._seed = value 2101 2102 def _stream_seed (self, name: str) -> typing.Optional[int]: 2103 2104 """ 2105 Derive the effective integer seed for a named random stream. 2106 2107 The derivation is ``zlib.crc32(f"{seed}:{name}")`` — crc32 rather 2108 than ``hash()`` because it is stable across processes — plus the 2109 per-name nonce when ``reroll()`` has been called. Returns None when 2110 the composition is unseeded. 2111 """ 2112 2113 if self._seed is None: 2114 return None 2115 2116 nonce = self._reroll_nonces.get(name, 0) 2117 key = f"{self._seed}:{name}" if nonce == 0 else f"{self._seed}:{name}:{nonce}" 2118 return zlib.crc32(key.encode()) 2119 2120 def _stream (self, name: str) -> typing.Optional[random.Random]: 2121 2122 """A fresh ``random.Random`` for a named stream, or None when unseeded.""" 2123 2124 stream_seed = self._stream_seed(name) 2125 return None if stream_seed is None else random.Random(stream_seed) 2126 2127 def seed_for (self, name: str) -> typing.Optional[int]: 2128 2129 """ 2130 Surface the effective derived seed for a named stream. 2131 2132 Works for pattern names and equally for any name you invent for a 2133 standalone value generator (``seed=composition.seed_for("hook")``), 2134 so its randomness keys off the composition seed without sharing any 2135 other consumer's stream. Reflects ``reroll()`` nonces. Returns None 2136 when the composition is unseeded. 2137 2138 Example: 2139 ```python 2140 hook_seed = composition.seed_for("hook") 2141 ``` 2142 """ 2143 2144 return self._stream_seed(name) 2145 2146 def reroll (self, name: str) -> None: 2147 2148 """ 2149 Deal a named stream a fresh deterministic seed — try a new variation. 2150 2151 Bumps the per-name nonce and prints the new effective seed. The 2152 nonce lives only in this process, so the printed seed is what lets a 2153 variation you like survive a restart: note it down, or ``lock()`` the 2154 name to pin it for the session. Refuses on locked names. 2155 2156 Parameters: 2157 name: The stream name — usually a pattern name. 2158 2159 Example: 2160 ```python 2161 comp.reroll("lead") # prints: reroll('lead') -> effective seed ... 2162 ``` 2163 """ 2164 2165 if name in self._locked_names: 2166 print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first") 2167 return 2168 2169 self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 2170 effective = self._stream_seed(name) 2171 2172 if effective is None: 2173 print(f"reroll('{name}'): composition has no seed - randomness is unseeded") 2174 return 2175 2176 running = self._running_patterns.get(name) 2177 2178 if running is not None and hasattr(running, "_rng"): 2179 running._rng = random.Random(effective) 2180 2181 print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})") 2182 2183 def lock (self, name: str) -> None: 2184 2185 """ 2186 Pin a named stream: keep its current effective seed and realization. 2187 2188 Engine-side state, so it survives live reload (it is never a builder 2189 swap): a locked pattern re-deals its stream from the same effective 2190 seed on every rebuild, so every cycle realizes identically, and 2191 ``reroll()`` refuses with a message until ``unlock()``. 2192 2193 Parameters: 2194 name: The stream name — usually a pattern name. 2195 """ 2196 2197 self._locked_names.add(name) 2198 2199 def unlock (self, name: str) -> None: 2200 2201 """Release a ``lock()``: the stream runs free and ``reroll()`` works again.""" 2202 2203 self._locked_names.discard(name) 2204 2205 def tuning ( 2206 self, 2207 source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, 2208 *, 2209 cents: typing.Optional[typing.List[float]] = None, 2210 ratios: typing.Optional[typing.List[float]] = None, 2211 equal: typing.Optional[int] = None, 2212 bend_range: float = 2.0, 2213 channels: typing.Optional[typing.List[int]] = None, 2214 reference_note: int = 60, 2215 exclude_drums: bool = True, 2216 ) -> None: 2217 2218 """Set a global microtonal tuning for the composition. 2219 2220 The tuning is applied automatically after each pattern rebuild (before 2221 the pattern is scheduled). Drum patterns (those registered with a 2222 ``drum_note_map``) are excluded by default. 2223 2224 Supply exactly one of the source parameters: 2225 2226 - ``source``: path to a Scala ``.scl`` file. 2227 - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). 2228 - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). 2229 - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). 2230 2231 For polyphonic parts, supply a ``channels`` pool. Notes are spread 2232 across those MIDI channels so each can carry an independent pitch bend. 2233 The synth must be configured to match ``bend_range`` (its pitch-bend range 2234 setting in semitones). 2235 2236 Parameters: 2237 source: Path to a ``.scl`` file. 2238 cents: Cent offsets for scale degrees 1..N. 2239 ratios: Frequency ratios for scale degrees 1..N. 2240 equal: Number of equal divisions of the period. 2241 bend_range: Synth pitch-bend range in semitones (default ±2). 2242 channels: Channel pool for polyphonic rotation. 2243 reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). 2244 exclude_drums: When True (default), skip patterns that have a 2245 ``drum_note_map`` (they use fixed GM pitches, not tuned ones). 2246 2247 Example: 2248 ```python 2249 # Quarter-comma meantone from a Scala file 2250 comp.tuning("meanquar.scl") 2251 2252 # Just intonation from ratios 2253 comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) 2254 2255 # 19-TET, monophonic 2256 comp.tuning(equal=19, bend_range=2.0) 2257 2258 # 31-TET with channel rotation for polyphony (channels 1-6) 2259 comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) 2260 ``` 2261 """ 2262 import subsequence.tuning as _tuning_mod 2263 2264 given = sum(x is not None for x in [source, cents, ratios, equal]) 2265 if given == 0: 2266 raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal") 2267 if given > 1: 2268 raise ValueError("composition.tuning() accepts only one source parameter") 2269 2270 if source is not None: 2271 t = _tuning_mod.Tuning.from_scl(source) 2272 elif cents is not None: 2273 t = _tuning_mod.Tuning.from_cents(cents) 2274 elif ratios is not None: 2275 t = _tuning_mod.Tuning.from_ratios(ratios) 2276 else: 2277 t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] 2278 2279 self._tuning = t 2280 self._tuning_bend_range = bend_range 2281 self._tuning_channels = channels 2282 self._tuning_reference_note = reference_note 2283 self._tuning_exclude_drums = exclude_drums 2284 2285 def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None: 2286 2287 """ 2288 Enable or disable the live terminal dashboard. 2289 2290 When enabled, Subsequence uses a safe logging handler that allows a 2291 persistent status line (BPM, Key, Bar, Section, Chord) to stay at 2292 the bottom of the terminal while logs scroll above it. 2293 2294 Parameters: 2295 enabled: Whether to show the display (default True). 2296 grid: When True, render an ASCII grid visualisation of all 2297 running patterns above the status line. The grid updates 2298 once per bar, showing which steps have notes and at what 2299 velocity. 2300 grid_scale: Horizontal zoom factor for the grid (default 2301 ``1.0``). Higher values add visual columns between 2302 grid steps, revealing micro-timing from swing and groove. 2303 Snapped to the nearest integer internally for uniform 2304 marker spacing. 2305 """ 2306 2307 if enabled: 2308 self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale) 2309 else: 2310 self._display = None 2311 2312 def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: 2313 2314 """ 2315 Enable the realtime Web UI Dashboard. 2316 2317 When enabled, Subsequence instantiates a WebSocket server that broadcasts 2318 the current state, signals, and active patterns (with high-res timing and 2319 note data) to any connected browser clients. 2320 2321 Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` 2322 (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but 2323 broadcasts full composition state, so only do so on a trusted network. 2324 """ 2325 2326 self._web_ui_enabled = True 2327 self._web_ui_http_host = http_host 2328 self._web_ui_ws_host = ws_host 2329 2330 def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None: 2331 2332 """ 2333 Configure a MIDI input device for external sync and MIDI messages. 2334 2335 May be called multiple times to register additional input devices. 2336 The first call sets the primary input (device 0). Subsequent calls 2337 add additional input devices (device 1, 2, …). Only one device may 2338 have ``clock_follow=True``. 2339 2340 Parameters: 2341 device: The name of the MIDI input port. 2342 clock_follow: If True, Subsequence will slave its clock to incoming 2343 MIDI Ticks. It will also follow MIDI Start/Stop/Continue 2344 commands. Only one device can have this enabled at a time. 2345 name: Optional alias for use with ``cc_map(input_device=…)`` and 2346 ``cc_forward(input_device=…)``. When omitted, the raw device 2347 name is used. 2348 2349 Example: 2350 ```python 2351 # Single controller (unchanged usage) 2352 comp.midi_input("Scarlett 2i4", clock_follow=True) 2353 2354 # Multiple controllers 2355 comp.midi_input("Arturia KeyStep", name="keys") 2356 comp.midi_input("Faderfox EC4", name="faders") 2357 ``` 2358 """ 2359 2360 if clock_follow: 2361 if self.is_clock_following: 2362 raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)") 2363 2364 if self._input_device is None: 2365 # First call: set primary input device (device 0) 2366 self._input_device = device 2367 self._input_device_alias = name 2368 self._clock_follow = clock_follow 2369 else: 2370 # Subsequent calls: register additional input devices 2371 self._additional_inputs.append((device, name, clock_follow)) 2372 2373 def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int: 2374 2375 """ 2376 Register an additional MIDI output device. 2377 2378 The first output device is always the one passed to 2379 ``Composition(output_device=…)`` — that is device 0. 2380 Each call to ``midi_output()`` adds the next device (1, 2, …). 2381 2382 Parameters: 2383 device: The exact name of the MIDI output port, as reported 2384 by ``mido.get_output_names()``. Matching is strict — 2385 partial names and substrings are rejected. See 2386 ``Composition.__init__`` for the lookup snippet and a 2387 note on ALSA name stability on Linux. 2388 name: Optional alias for use with ``pattern(device=…)``, 2389 ``cc_forward(output_device=…)``, etc. When omitted, the raw 2390 device name is used. 2391 latency_ms: Physical output latency of this device in 2392 milliseconds, for delay compensation (default 0.0, must be 2393 non-negative). Set this when the device sounds late (e.g. a 2394 software sampler) so Subsequence delays faster devices to 2395 line everything up. 2396 2397 Returns: 2398 The integer device index assigned (1, 2, 3, …). 2399 2400 Example: 2401 ```python 2402 comp = subsequence.Composition(bpm=120, output_device="MOTU Express") 2403 2404 # Returns 1 — use as device=1 or device="integra" 2405 comp.midi_output("Roland Integra", name="integra") 2406 2407 # A software sampler that sounds 20ms late 2408 comp.midi_output("Subsample", name="sampler", latency_ms=20) 2409 2410 @comp.pattern(channel=1, beats=4, device="integra") 2411 def strings (p): 2412 p.note(60, beat=0) 2413 ``` 2414 """ 2415 2416 if latency_ms < 0: 2417 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 2418 2419 idx = 1 + len(self._additional_outputs) # device 0 is always the primary 2420 self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms)) 2421 return idx 2422 2423 def _warn_if_high_latency (self) -> None: 2424 2425 """Warn if delay compensation adds a large whole-rig latency. 2426 2427 The slowest device defines the alignment point — every faster device is 2428 delayed up to that amount — so a large maximum means the whole rig 2429 responds late to live input. Emitted once at startup. 2430 """ 2431 2432 candidates: typing.List[typing.Tuple[str, float]] = [("primary output", self._output_latency_ms)] 2433 candidates += [(out.alias or out.device, out.latency_ms) for out in self._additional_outputs] 2434 2435 slow_name, max_ms = max(candidates, key=lambda c: c[1]) 2436 2437 if max_ms > _LATENCY_WARN_THRESHOLD_MS: 2438 logger.warning( 2439 "Device latency compensation: '%s' is the slowest at %.0fms, so faster " 2440 "devices are delayed up to %.0fms to stay aligned — live-input feel may suffer.", 2441 slow_name, max_ms, max_ms, 2442 ) 2443 2444 def clock_output (self, enabled: bool = True) -> None: 2445 2446 """ 2447 Send MIDI timing clock to connected hardware. 2448 2449 When enabled, Subsequence acts as a MIDI clock master and sends 2450 standard clock messages on the output port: a Start message (0xFA) 2451 when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), 2452 and a Stop message (0xFC) when playback ends. 2453 2454 This allows hardware synthesizers, drum machines, and effect units to 2455 slave their tempo to Subsequence automatically. 2456 2457 **Note:** Clock output is automatically disabled when ``midi_input()`` 2458 is called with ``clock_follow=True``, to prevent a clock feedback loop. 2459 2460 Parameters: 2461 enabled: Whether to send MIDI clock (default True). 2462 2463 Example: 2464 ```python 2465 comp = subsequence.Composition(bpm=120, output_device="...") 2466 comp.clock_output() # hardware will follow Subsequence tempo 2467 ``` 2468 """ 2469 2470 self._clock_output = enabled 2471 2472 2473 def link (self, quantum: float = 4.0) -> "Composition": 2474 2475 """ 2476 Enable Ableton Link tempo and phase synchronisation. 2477 2478 When enabled, Subsequence joins the local Link session and slaves its 2479 clock to the shared network tempo and beat phase. All other Link-enabled 2480 apps on the same LAN — Ableton Live, iOS synths, other Subsequence 2481 instances — will automatically stay in time. 2482 2483 Playback starts on the next bar boundary aligned to the Link quantum, 2484 so downbeats stay in sync across all participants. 2485 2486 Requires the ``link`` optional extra:: 2487 2488 pip install subsequence[link] 2489 2490 Parameters: 2491 quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. 2492 Change this if your composition uses a different meter. 2493 2494 Example:: 2495 2496 comp = subsequence.Composition(bpm=120, key="C") 2497 comp.link() # join the Link session 2498 comp.play() 2499 2500 # On another machine / instance: 2501 comp2 = subsequence.Composition(bpm=120) 2502 comp2.link() # tempo and phase will lock to comp 2503 comp2.play() 2504 2505 Note: 2506 ``set_bpm()`` proposes the new tempo to the Link network when Link 2507 is active. The network-authoritative tempo is applied on the next 2508 pulse, so there may be a brief lag before the change is visible. 2509 """ 2510 2511 # Eagerly check that aalink is installed — fail early with a clear message. 2512 subsequence.link_clock._require_aalink() 2513 2514 self._link_quantum = quantum 2515 return self 2516 2517 2518 def cc_map ( 2519 self, 2520 cc: int, 2521 data_key: str, 2522 channel: typing.Optional[int] = None, 2523 min_val: float = 0.0, 2524 max_val: float = 1.0, 2525 input_device: subsequence.midi_utils.DeviceId = None, 2526 ) -> None: 2527 2528 """ 2529 Map an incoming MIDI CC to a ``composition.data`` key. 2530 2531 When the composition receives a CC message on the configured MIDI 2532 input port, the value is scaled from the CC range (0–127) to 2533 *[min_val, max_val]* and stored in ``composition.data[data_key]``. 2534 2535 This lets hardware knobs, faders, and expression pedals control live 2536 parameters without writing any callback code. 2537 2538 **Requires** ``midi_input()`` to be called first to open an input port. 2539 2540 Parameters: 2541 cc: MIDI Control Change number (0–127). 2542 data_key: The ``composition.data`` key to write. 2543 channel: If given, only respond to CC messages on this channel. 2544 Uses the same numbering convention as ``pattern()`` (1-16 2545 by default, or 0-15 with ``zero_indexed_channels=True``). 2546 ``None`` matches any channel (default). 2547 min_val: Scaled minimum — written when CC value is 0 (default 0.0). 2548 max_val: Scaled maximum — written when CC value is 127 (default 1.0). 2549 input_device: Only respond to CC messages from this input device 2550 (index or name). ``None`` responds to any input device (default). 2551 2552 Example: 2553 ```python 2554 comp.midi_input("Arturia KeyStep") 2555 comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 2556 comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader 2557 2558 # Multi-device: only listen to CC 74 from the "faders" controller 2559 comp.cc_map(74, "filter", input_device="faders") 2560 ``` 2561 """ 2562 2563 resolved_channel = self._resolve_channel(channel) if channel is not None else None 2564 2565 self._cc_mappings.append({ 2566 'cc': cc, 2567 'data_key': data_key, 2568 'channel': resolved_channel, 2569 'min_val': min_val, 2570 'max_val': max_val, 2571 'input_device': input_device, # resolved to int index in _run() 2572 }) 2573 2574 2575 def note_input ( 2576 self, 2577 channel: typing.Optional[int] = None, 2578 release_ms: float = 30.0, 2579 latch: bool = False, 2580 input_device: subsequence.midi_utils.DeviceId = None, 2581 ) -> None: 2582 2583 """Track notes held on a MIDI keyboard for live arpeggiation. 2584 2585 Incoming note-on/note-off messages build a live "currently held" set 2586 that any pattern reads via ``p.held_notes()`` — typically fed straight 2587 to ``p.arpeggio()``. The composition still authors the rhythm and 2588 motion; the player's hands supply the pitch set. This is a live 2589 *performance* layer over the deterministic, seeded composition: when 2590 rendering headlessly there is no input, so ``p.held_notes()`` is empty 2591 and seeded output is unchanged. 2592 2593 **Requires** ``midi_input()`` to be called first to open an input port. 2594 2595 Parameters: 2596 channel: If given, only track notes on this channel. Uses the same 2597 numbering convention as ``pattern()`` (1-16 by default, or 0-15 2598 with ``zero_indexed_channels=True``). ``None`` tracks any 2599 channel (default). 2600 release_ms: How long (milliseconds) a released note keeps counting 2601 as held. This smooths the momentary all-keys-up gap during a 2602 hand-position change so the arp does not drop to silence. 2603 Default 30.0; set 0.0 to release instantly. Ignored when 2604 ``latch`` is True. 2605 latch: When True, the held set persists after you lift your hands 2606 until you play a new chord (the first key after every key is up 2607 replaces it) — like a hardware arp's latch. 2608 input_device: Only track notes from this input device (index or 2609 name). ``None`` tracks any input device (default). 2610 2611 Example: 2612 ```python 2613 comp.midi_input("Arturia KeyStep") 2614 comp.note_input(channel=1, release_ms=30) 2615 2616 @comp.pattern(channel=6, beats=4) 2617 def arp (p): 2618 p.arpeggio(p.held_notes(), direction="up") # rests when silent 2619 ``` 2620 """ 2621 2622 if self._note_input is not None: 2623 raise RuntimeError("only one note_input source is supported — named multi-source is not yet available") 2624 2625 resolved_channel = self._resolve_channel(channel) if channel is not None else None 2626 2627 self._note_input = { 2628 'channel': resolved_channel, 2629 'release_ms': release_ms, 2630 'latch': latch, 2631 'input_device': input_device, # resolved to int index in _run() 2632 } 2633 2634 2635 @staticmethod 2636 def _make_cc_forward_transform ( 2637 output: typing.Union[str, typing.Callable], 2638 cc: int, 2639 output_channel: typing.Optional[int], 2640 ) -> typing.Callable: 2641 2642 """Build a transform callable from a preset string or user-supplied callable. 2643 2644 The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]`` 2645 where ``channel`` is the 0-indexed incoming channel. 2646 """ 2647 2648 import mido as _mido 2649 2650 def _out_ch (incoming: int) -> int: 2651 return output_channel if output_channel is not None else incoming 2652 2653 if callable(output): 2654 if output_channel is None: 2655 return output 2656 def _wrapped (value: int, channel: int) -> typing.Optional[typing.Any]: 2657 msg = output(value, channel) 2658 2659 if msg is None: 2660 return None 2661 2662 # copy() re-channels without rebuilding: reconstructing from 2663 # __dict__ passed 'type' twice and raised TypeError on every 2664 # message, so callable+output_channel never forwarded anything. 2665 return msg.copy(channel=output_channel) 2666 return _wrapped 2667 2668 if output == 'cc': 2669 def _cc_identity (value: int, channel: int) -> typing.Any: 2670 return _mido.Message('control_change', channel=_out_ch(channel), control=cc, value=value) 2671 return _cc_identity 2672 2673 if output.startswith('cc:'): 2674 try: 2675 target_cc = int(output[3:]) 2676 except ValueError: 2677 raise ValueError(f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127") 2678 if not 0 <= target_cc <= 127: 2679 raise ValueError(f"cc_forward(): CC number {target_cc} out of range 0–127") 2680 def _cc_remap (value: int, channel: int) -> typing.Any: 2681 return _mido.Message('control_change', channel=_out_ch(channel), control=target_cc, value=value) 2682 return _cc_remap 2683 2684 if output == 'pitchwheel': 2685 def _pitchwheel (value: int, channel: int) -> typing.Any: 2686 pitch = int(value / 127 * 16383) - 8192 2687 return _mido.Message('pitchwheel', channel=_out_ch(channel), pitch=pitch) 2688 return _pitchwheel 2689 2690 raise ValueError( 2691 f"cc_forward(): unknown preset '{output}'. " 2692 "Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable." 2693 ) 2694 2695 2696 def cc_forward ( 2697 self, 2698 cc: int, 2699 output: typing.Union[str, typing.Callable], 2700 *, 2701 channel: typing.Optional[int] = None, 2702 output_channel: typing.Optional[int] = None, 2703 mode: str = "instant", 2704 input_device: subsequence.midi_utils.DeviceId = None, 2705 output_device: subsequence.midi_utils.DeviceId = None, 2706 ) -> None: 2707 2708 """ 2709 Forward an incoming MIDI CC to the MIDI output in real-time. 2710 2711 Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` 2712 for use at pattern rebuild time, ``cc_forward()`` routes the signal 2713 directly to the MIDI output — bypassing the pattern cycle entirely. 2714 2715 Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC 2716 number; they operate independently. 2717 2718 Parameters: 2719 cc: Incoming CC number to listen for (0–127). 2720 output: What to send. Either a **preset string**: 2721 2722 - ``"cc"`` — identity forward, same CC number and value. 2723 - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). 2724 - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. 2725 2726 Or a **callable** with signature 2727 ``(value: int, channel: int) -> Optional[mido.Message]``. 2728 Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. 2729 ``channel`` is 0-indexed (the incoming channel). 2730 channel: If given, only respond to CC messages on this channel. 2731 Uses the same numbering convention as ``cc_map()``. 2732 ``None`` matches any channel (default). 2733 output_channel: Override the output channel. ``None`` uses the 2734 incoming channel. Uses the same numbering convention as ``pattern()``. 2735 mode: Dispatch mode: 2736 2737 - ``"instant"`` *(default)* — send immediately on the MIDI input 2738 callback thread. Lowest latency (~1–5 ms). Instant forwards are 2739 **not** recorded when recording is enabled. 2740 - ``"queued"`` — inject into the sequencer event queue and send at 2741 the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards 2742 **are** recorded when recording is enabled. 2743 2744 Example: 2745 ```python 2746 comp.midi_input("Arturia KeyStep") 2747 2748 # CC 1 → CC 1 (identity, instant) 2749 comp.cc_forward(1, "cc") 2750 2751 # CC 1 → pitch bend on channel 1, queued (recordable) 2752 comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") 2753 2754 # CC 1 → CC 74, custom channel 2755 comp.cc_forward(1, "cc:74", output_channel=2) 2756 2757 # Custom transform — remap CC range 0–127 to CC 74 range 40–100 2758 import subsequence.midi as midi 2759 comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) 2760 2761 # Forward AND map to data simultaneously — both active on the same CC 2762 comp.cc_map(1, "mod_wheel") 2763 comp.cc_forward(1, "cc:74") 2764 ``` 2765 """ 2766 2767 if not 0 <= cc <= 127: 2768 raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") 2769 2770 if mode not in ('instant', 'queued'): 2771 raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'") 2772 2773 resolved_in_channel = self._resolve_channel(channel) if channel is not None else None 2774 resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None 2775 2776 transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) 2777 2778 self._cc_forwards.append({ 2779 'cc': cc, 2780 'channel': resolved_in_channel, 2781 'output_channel': resolved_out_channel, 2782 'mode': mode, 2783 'transform': transform, 2784 'input_device': input_device, # resolved to int index in _run() 2785 'output_device': output_device, # resolved to int index in _run() 2786 }) 2787 2788 2789 def live (self, port: int = 5555) -> None: 2790 2791 """ 2792 Enable the live coding eval server. 2793 2794 This allows you to connect to a running composition using the 2795 `subsequence.live_client` REPL and hot-swap pattern code or 2796 modify variables in real-time. 2797 2798 Security: 2799 The server executes arbitrary Python in this process — it is **not** a 2800 sandbox. It binds to localhost only and is opt-in, but any process on 2801 the same machine that can reach the port gains full code execution here. 2802 Do not enable it on shared or multi-user hosts, and never expose the 2803 port to a network. 2804 2805 Parameters: 2806 port: The TCP port to listen on (default 5555). 2807 """ 2808 2809 self._live_server = subsequence.live_server.LiveServer(self, port=port) 2810 self._is_live = True 2811 2812 def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None: 2813 2814 """Watch a Python file and reload it into the composition on every save. 2815 2816 The watched file is exec'd into a namespace with ``composition`` and 2817 ``subsequence`` available. ``@composition.pattern`` decorators inside 2818 the file hot-swap their corresponding running patterns in place; 2819 patterns whose function bodies have been deleted from the file are 2820 unregistered automatically on the next reload (notes stopped, 2821 removed from the running-pattern set). 2822 2823 An **initial synchronous load** happens here — if the file has a 2824 ``SyntaxError`` or doesn't exist at this moment, the exception 2825 propagates so the user knows immediately. Subsequent reloads 2826 happen on the composition's event loop and tolerate transient 2827 errors (logged, skipped). 2828 2829 Call BEFORE ``composition.play()``. Reloads happen on the 2830 composition's event loop, so all mutations are thread-safe. 2831 2832 See the "Live coding via file watching" section of the README for 2833 the recommended wrapper-script + live-file split. 2834 2835 Parameters: 2836 path: Path to the Python file to watch. 2837 poll_interval: Seconds between ``mtime`` polls (default 0.25 s). 2838 2839 Example:: 2840 2841 # live_init.py — runs once 2842 composition = subsequence.Composition(bpm=120, key="E") 2843 composition.harmony(style="aeolian_minor") 2844 composition.watch("live_patterns.py") 2845 composition.play() 2846 """ 2847 2848 # Required for the decorator hot-swap path to fire on re-decoration. 2849 self._is_live = True 2850 2851 # Detect the single-file workflow: if watch() is called from inside 2852 # the very file being watched, the outer Python script execution will 2853 # already register the patterns (the decorators sit at module level 2854 # below ``watch(__file__)``). In that case, _load_initial's re-exec 2855 # would double-register every pattern, so skip it. For the two-file 2856 # workflow (path != caller's __file__) the initial exec is essential 2857 # — it's the only way the watched file's patterns ever reach the 2858 # composition. 2859 caller_file = self._caller_module_file() 2860 self_watch = False 2861 if caller_file is not None: 2862 try: 2863 self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() 2864 except OSError: 2865 self_watch = False 2866 2867 self._live_reloader = subsequence.live_reloader.LiveReloader( 2868 composition = self, 2869 path = path, 2870 poll_interval = poll_interval, 2871 skip_initial_exec = self_watch, 2872 ) 2873 self._live_reloader.start() 2874 2875 @staticmethod 2876 def _caller_module_file () -> typing.Optional[str]: 2877 2878 """Return ``__file__`` of the module that invoked the caller, if available. 2879 2880 Walks one frame up the call stack — the immediate caller is 2881 ``watch()``, so ``f_back`` is the user's code. Returns the 2882 module-level ``__file__`` of that frame's globals; ``None`` when 2883 the caller has no ``__file__`` (REPL, exec'd context, etc.). 2884 """ 2885 2886 frame = inspect.currentframe() 2887 if frame is None or frame.f_back is None or frame.f_back.f_back is None: 2888 return None 2889 # f_back = watch(); f_back.f_back = user code calling watch(). 2890 return frame.f_back.f_back.f_globals.get("__file__") 2891 2892 def load_patterns ( 2893 self, 2894 source: str, 2895 source_label: str = "<string>", 2896 ) -> None: 2897 2898 """Compile and apply a pattern-source string to the composition. 2899 2900 Equivalent to one ``watch()`` reload triggered by save, but with the 2901 source presented in-memory rather than on disk. Useful for web / 2902 REST handlers that accept pattern uploads from a trusted contributor, 2903 or for one-shot session loads with no file backing. 2904 2905 Behaviour mirrors ``watch()``: 2906 * The source is exec'd into a fresh namespace with ``composition`` 2907 and ``subsequence`` in scope. 2908 * ``@composition.pattern`` decorators in the source hot-swap their 2909 corresponding running patterns in place. 2910 * Patterns currently running but **not** declared in the source are 2911 unregistered — the source is treated as the full new truth. 2912 * If the composition is already playing, the swap happens on the 2913 event loop thread; the call blocks until it completes. 2914 * If the composition has not yet called ``play()``, the source runs 2915 on the caller's thread; decorators populate ``_pending_patterns`` 2916 and ``play()`` picks them up in the usual way. 2917 2918 Errors are raised so the caller can act on them: 2919 * ``SyntaxError`` if ``source`` fails to compile. 2920 * The exception raised inside ``exec()`` for any runtime error. 2921 * ``RuntimeError`` if called from inside the composition's own 2922 event loop thread (would deadlock — see Threading below). 2923 2924 In either failure case, existing composition state is preserved — 2925 the diff-and-unregister phase is skipped if exec raised, so a 2926 half-broken upload cannot tear down working patterns. 2927 2928 Threading: 2929 Designed to be called from a thread DIFFERENT from the 2930 composition's event loop — typically a web-handler worker. 2931 Cannot be called from inside the loop itself (a pattern 2932 callback, an asyncio task spawned by the composition). From 2933 there, ``await composition._apply_source_async(...)`` directly. 2934 2935 SECURITY WARNING: ``exec()`` is not sandboxed. The source has full 2936 Python access in this process. Only pass source from trusted 2937 senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, 2938 ``exit``, ``quit``) prevents calls that would stall the event loop; 2939 it is not a security boundary. 2940 2941 Parameters: 2942 source: Python source declaring ``@composition.pattern`` 2943 functions. 2944 source_label: Identifier used in compile errors and tracebacks 2945 (appears as the filename in ``SyntaxError`` and ``__file__``- 2946 style traceback lines). Default ``"<string>"``. 2947 """ 2948 2949 # Required for the decorator hot-swap path to fire on re-decoration. 2950 self._is_live = True 2951 2952 # Compile on the caller's thread so SyntaxError comes back fast, 2953 # before any cross-thread scheduling. 2954 compiled = compile(source, source_label, "exec") 2955 namespace = self._build_live_namespace(source_label = source_label) 2956 2957 loop = self._sequencer._event_loop 2958 2959 if loop is not None and loop.is_running(): 2960 2961 # Refuse to deadlock: calling load_patterns() from inside the 2962 # composition's own event loop (e.g. from a pattern callback or 2963 # an asyncio task spawned by the composition) would have us 2964 # block waiting for a coroutine that can only run when this 2965 # thread yields. Tell the caller exactly what to do instead. 2966 try: 2967 current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() 2968 except RuntimeError: 2969 current_loop = None 2970 2971 if current_loop is loop: 2972 raise RuntimeError( 2973 "load_patterns() cannot be called from inside the composition's " 2974 "event loop thread — it would deadlock waiting for the " 2975 "scheduled coroutine to run on the very thread that's blocked. " 2976 "From a worker thread, call it normally. From an async " 2977 "coroutine already on the loop, " 2978 "`await composition._apply_source_async(compile(source, label, 'exec'), " 2979 "composition._build_live_namespace())` instead." 2980 ) 2981 2982 # Composition is playing — mutation must happen on the loop thread. 2983 # future.result() blocks the caller until the coroutine finishes 2984 # and re-raises any exception it threw. 2985 future = asyncio.run_coroutine_threadsafe( 2986 self._apply_source_async(compiled, namespace), 2987 loop = loop, 2988 ) 2989 future.result() 2990 2991 else: 2992 # Pre-play: no event loop yet. Decorators populate 2993 # _pending_patterns; play() graduates them in the usual way. 2994 # Diff-and-unregister is unnecessary here — nothing is running. 2995 exec(compiled, namespace) 2996 2997 async def _apply_source_async ( 2998 self, 2999 compiled: types.CodeType, 3000 namespace: typing.Dict[str, typing.Any], 3001 ) -> None: 3002 3003 """Execute pre-compiled live source against the running composition. 3004 3005 Runs on the event loop thread. Performs ``exec()``, graduates any 3006 newly-decorated patterns into ``_running_patterns``, then unregisters 3007 any patterns that were running but absent from the source. 3008 3009 Raises whatever ``exec()`` raises. When that happens, the diff-and- 3010 unregister phase is skipped — the namespace is incomplete, so any 3011 patterns the source failed to reach would be misinterpreted as 3012 deletions and torn down. 3013 3014 Called from two places: 3015 * ``Composition.load_patterns()`` via ``run_coroutine_threadsafe``. 3016 * ``LiveReloader._reload_async`` directly (already on the loop). 3017 """ 3018 3019 # Track which patterns the source declares this exec. pattern() and 3020 # layer() add their (resolved) names to _declared_names as they run, so 3021 # this covers decorated patterns AND layer()/merged patterns — the latter 3022 # have no module-level callable to match against by name, which is why the 3023 # old namespace-based diff tore layers down on every reload. 3024 self._declared_names = set() 3025 3026 # Bail before any state mutation if exec raises — propagates to 3027 # the caller (load_patterns re-raises; LiveReloader catches + logs). 3028 exec(compiled, namespace) 3029 3030 # Graduate newly-decorated patterns from _pending_patterns into 3031 # _running_patterns so they start firing on the next reschedule. 3032 # Patterns that hot-swapped via the decorator/layer path don't appear 3033 # in _pending_patterns and don't need this step. 3034 await self._activate_new_pending_patterns() 3035 3036 # Detect deletions: anything currently running but NOT declared by the 3037 # just-exec'd source has been removed by the user and should be torn 3038 # down. Decorators/layer() do NOT remove from _running_patterns when a 3039 # definition disappears from the source. 3040 for name in list(self._running_patterns.keys()): 3041 if name not in self._declared_names: 3042 self.unregister(name) 3043 3044 def _build_live_namespace (self, source_label: str = "<live>") -> typing.Dict[str, typing.Any]: 3045 3046 """Build a fresh namespace dict for exec'ing live source. 3047 3048 Provides ``composition`` (this Composition), ``subsequence`` (the 3049 package), and a safe builtins set with ``help``, ``input``, 3050 ``breakpoint``, ``exit``, ``quit`` blocked. 3051 3052 Also injects two dunder globals that make the single-file live-coding 3053 workflow ergonomic: 3054 3055 * ``__name__ = "__live_reload__"`` — so ``if __name__ == "__main__":`` 3056 blocks in the watched file are *skipped* during live reload. The 3057 same file run directly with ``python my_session.py`` sees 3058 ``__name__ == "__main__"`` and runs setup; saves trigger reload 3059 with ``__name__ == "__live_reload__"``, skipping setup and only 3060 re-running pattern definitions. 3061 * ``__file__ = source_label`` — so ``composition.watch(__file__)`` 3062 and any user code referencing ``__file__`` works inside the live 3063 namespace. Set to the file path for ``LiveReloader``, the 3064 user-supplied ``source_label`` for ``Composition.load_patterns``, 3065 and ``"<live>"`` for ``LiveServer``. 3066 3067 Single source of truth: ``live_reloader`` (file watching), 3068 ``live_server`` (TCP REPL), and ``load_patterns`` (string source) 3069 all call this so live source sees the same environment from any 3070 entry point. 3071 3072 The blocklist prevents calls that would stall the async event loop 3073 running the sequencer. It is **not** a security sandbox — exec'd 3074 code can still do anything Python allows. 3075 3076 Parameters: 3077 source_label: Value to bind to ``__file__`` in the namespace. 3078 Defaults to ``"<live>"``. 3079 """ 3080 3081 import subsequence # local import: this module is imported during subsequence init 3082 3083 safe_builtins = {name: getattr(builtins, name) for name in dir(builtins)} 3084 3085 blocked = {"help", "input", "breakpoint", "exit", "quit"} 3086 3087 for name in blocked: 3088 safe_builtins[name] = _live_blocked(name) 3089 3090 return { 3091 "__builtins__": safe_builtins, 3092 "__name__": "__live_reload__", 3093 "__file__": source_label, 3094 "composition": self, 3095 "subsequence": subsequence, 3096 } 3097 3098 def osc (self, receive_port: int = 9000, send_port: int = 9001, send_host: str = "127.0.0.1", receive_host: str = "0.0.0.0") -> None: 3099 3100 """ 3101 Enable bi-directional Open Sound Control (OSC). 3102 3103 Subsequence will listen for commands (like `/bpm` or `/mute`) and 3104 broadcast its internal state (like `/chord` or `/bar`) over UDP. 3105 3106 Parameters: 3107 receive_port: Port to listen for incoming OSC messages (default 9000). 3108 send_port: Port to send state updates to (default 9001). 3109 send_host: The IP address to send updates to (default "127.0.0.1"). 3110 receive_host: Interface to listen on (default "0.0.0.0" — all 3111 interfaces, so external OSC controllers on the LAN can reach it). 3112 The listener can change tempo, mute patterns, and write data, so on 3113 an untrusted network restrict it with ``receive_host="127.0.0.1"``. 3114 """ 3115 3116 self._osc_server = subsequence.osc.OscServer( 3117 self, 3118 receive_port = receive_port, 3119 send_port = send_port, 3120 send_host = send_host, 3121 receive_host = receive_host 3122 ) 3123 3124 def osc_map (self, address: str, handler: typing.Callable) -> None: 3125 3126 """ 3127 Register a custom OSC handler. 3128 3129 Must be called after :meth:`osc` has been configured. 3130 3131 Parameters: 3132 address: OSC address pattern to match (e.g. ``"/my/param"``). 3133 handler: Callable invoked with ``(address, *args)`` when a 3134 matching message arrives. 3135 3136 Example:: 3137 3138 composition.osc() 3139 3140 def on_intensity (address, value): 3141 composition.data["intensity"] = float(value) 3142 3143 composition.osc_map("/intensity", on_intensity) 3144 """ 3145 3146 if self._osc_server is None: 3147 raise RuntimeError("Call composition.osc() before composition.osc_map()") 3148 3149 self._osc_server.map(address, handler) 3150 3151 def set_bpm (self, bpm: float) -> None: 3152 3153 """ 3154 Instantly change the tempo. 3155 3156 Parameters: 3157 bpm: The new tempo in beats per minute. 3158 3159 When Ableton Link is active, this proposes the new tempo to the Link 3160 network instead of applying it locally. The network-authoritative tempo 3161 is picked up on the next pulse. 3162 """ 3163 3164 self._sequencer.set_bpm(bpm) 3165 3166 if not self.is_clock_following and self._link_quantum is None: 3167 self.bpm = bpm 3168 3169 def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None: 3170 3171 """ 3172 Smoothly ramp the tempo to a target value over a number of bars. 3173 3174 Parameters: 3175 bpm: Target tempo in beats per minute. 3176 bars: Duration of the transition in bars. 3177 shape: Easing curve name. Defaults to ``"linear"``. 3178 ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- 3179 sounding tempo changes. See :mod:`subsequence.easing` for all 3180 available shapes. 3181 3182 Example: 3183 ```python 3184 # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve 3185 comp.target_bpm(140, bars=8, shape="ease_in_out") 3186 ``` 3187 3188 Note: 3189 Ignored while Ableton Link is active — the shared session tempo is 3190 authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. 3191 """ 3192 3193 self._sequencer.set_target_bpm(bpm, bars, shape) 3194 3195 def live_info (self) -> typing.Dict[str, typing.Any]: 3196 3197 """ 3198 Return a dictionary containing the current state of the composition. 3199 3200 Includes BPM, key, current bar, active section, current chord, 3201 running patterns, and custom data. 3202 """ 3203 3204 section_info = None 3205 if self._form_state is not None: 3206 section = self._form_state.get_section_info() 3207 if section is not None: 3208 section_info = { 3209 "name": section.name, 3210 "bar": section.bar, 3211 "bars": section.bars, 3212 "progress": section.progress 3213 } 3214 3215 chord_name = None 3216 sounding_chord = self.current_chord() 3217 if sounding_chord is not None: 3218 chord_name = sounding_chord.name() 3219 3220 pattern_list = [] 3221 channel_offset = 0 if self._zero_indexed_channels else 1 3222 for name, pat in self._running_patterns.items(): 3223 pattern_list.append({ 3224 "name": name, 3225 "channel": pat.channel + channel_offset, 3226 "length": pat.length, 3227 "cycle": pat._cycle_count, 3228 "muted": pat._muted, 3229 "tweaks": dict(pat._tweaks) 3230 }) 3231 3232 return { 3233 "bpm": self._sequencer.current_bpm, 3234 "key": self.key, 3235 "bar": self._builder_bar, 3236 "section": section_info, 3237 "chord": chord_name, 3238 "patterns": pattern_list, 3239 "input_device": self._input_device, 3240 "clock_follow": self.is_clock_following, 3241 "data": self.data 3242 } 3243 3244 def mute (self, name: str) -> None: 3245 3246 """ 3247 Mute a running pattern by name. 3248 3249 The pattern continues to 'run' and increment its cycle count in 3250 the background, but it will not produce any MIDI notes until unmuted. 3251 3252 Parameters: 3253 name: The function name of the pattern to mute. 3254 """ 3255 3256 if name not in self._running_patterns: 3257 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3258 3259 self._running_patterns[name]._muted = True 3260 logger.info(f"Muted pattern: {name}") 3261 3262 def unmute (self, name: str) -> None: 3263 3264 """ 3265 Unmute a previously muted pattern. 3266 """ 3267 3268 if name not in self._running_patterns: 3269 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3270 3271 self._running_patterns[name]._muted = False 3272 logger.info(f"Unmuted pattern: {name}") 3273 3274 def unregister (self, name: str) -> None: 3275 3276 """Fully remove a running pattern from rotation. 3277 3278 Unlike ``mute()`` (which keeps the pattern alive but silent), 3279 ``unregister()`` tears the pattern down entirely. It sets 3280 ``pattern._removed = True`` so the sequencer's reschedule loop 3281 skips re-adding it on the next pulse; sends ``note_off`` for any 3282 of the pattern's currently-sounding notes on the primary 3283 destination AND on every mirror destination (so drones and 3284 sustaining notes stop immediately); and removes the entry from 3285 ``_running_patterns`` so it no longer appears in ``live_info()``, 3286 the terminal grid, or any other consumer that enumerates running 3287 patterns. 3288 3289 Already-queued events in the sequencer's event queue play out — 3290 note_offs are paired with their note_ons at queue time, so notes 3291 end at their natural duration; only drones rely on the targeted 3292 ``_stop_pattern_notes`` pass. 3293 3294 Idempotent: silently logs a ``debug`` and returns if the pattern 3295 is already absent. Useful from both the live REPL 3296 (``composition.live()``) and the file watcher 3297 (``composition.watch()``), which calls this for any pattern 3298 removed from the watched file between reloads. 3299 3300 Parameters: 3301 name: Function name of the pattern to remove. 3302 """ 3303 3304 if name not in self._running_patterns: 3305 logger.debug(f"unregister() no-op: pattern '{name}' not running") 3306 return 3307 3308 pattern = self._running_patterns[name] 3309 3310 # Mark for removal first so the reschedule loop sees the flag even if 3311 # it fires concurrently with the note-off pass below. 3312 pattern._removed = True 3313 3314 # Stop sustaining notes (including drones) on every destination this 3315 # pattern outputs to. Fire-and-forget across threads via the event 3316 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 3317 if self._sequencer._event_loop is not None: 3318 asyncio.run_coroutine_threadsafe( 3319 self._sequencer._stop_pattern_notes(pattern), 3320 loop = self._sequencer._event_loop, 3321 ) 3322 3323 def _finalise_removal () -> None: 3324 self._running_patterns.pop(name, None) 3325 3326 # Forget any pending (not-yet-graduated) declaration too, so a 3327 # later live reload cannot resurrect the pattern. 3328 self._pending_patterns = [ 3329 pending for pending in self._pending_patterns 3330 if pending.builder_fn.__name__ != name 3331 ] 3332 3333 logger.info(f"Unregistered pattern: {name}") 3334 3335 # The running-patterns dict is iterated by the display, web UI, and 3336 # reschedule loop on the event loop thread — mutate it there when this 3337 # call arrives from another thread (e.g. the live TCP server). 3338 loop = self._sequencer._event_loop 3339 3340 try: 3341 on_loop = loop is not None and asyncio.get_running_loop() is loop 3342 except RuntimeError: 3343 on_loop = False 3344 3345 if loop is not None and loop.is_running() and not on_loop: 3346 loop.call_soon_threadsafe(_finalise_removal) 3347 else: 3348 _finalise_removal() 3349 3350 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 3351 3352 """ 3353 Add a mirror destination to a running pattern. 3354 3355 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 3356 event the pattern emits will also be sent to ``(device, channel)``, 3357 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 3358 — calling with the same destination twice does not double-fan; calling 3359 again with a different ``drum_note_map`` re-points it in place. 3360 3361 Parameters: 3362 name: Function name of the pattern to mirror. 3363 device: Output device index (the integer returned from 3364 ``midi_output()``; 0 = primary device). 3365 channel: MIDI channel using this composition's numbering convention 3366 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 3367 drum_note_map: Optional per-destination drum map. When set, mirrored 3368 drum hits are re-resolved by name through it, so a named voice 3369 lands on this device's own note number — see the README 3370 "MIDI mirroring" section. 3371 3372 Bandwidth: each mirror adds another full copy of the pattern's events. 3373 See the README "MIDI mirroring" section for the tradeoffs. 3374 """ 3375 3376 if name not in self._running_patterns: 3377 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3378 3379 resolved_channel = self._resolve_channel(channel) 3380 prefix = (device, resolved_channel) 3381 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 3382 3383 pattern = self._running_patterns[name] 3384 3385 # Mirror-to-self check: comparing the (device, channel) prefix against the 3386 # live pattern's resolved destination. Unlike the decorator path this is 3387 # always concrete. 3388 if prefix == (pattern.device, pattern.channel): 3389 logger.warning( 3390 f"Mirror destination {prefix} matches '{name}'s primary destination " 3391 f"— every event will double-fire on this (device, channel). This is almost " 3392 f"certainly unintended." 3393 ) 3394 3395 # Idempotent on (device, channel): replace any existing entry for the same 3396 # destination (so its map can be re-pointed), else append. 3397 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 3398 if existing_index is None: 3399 pattern.mirrors.append(entry) 3400 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 3401 elif pattern.mirrors[existing_index] != entry: 3402 pattern.mirrors[existing_index] = entry 3403 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 3404 else: 3405 logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}") 3406 3407 def unmirror (self, name: str, device: int, channel: int) -> None: 3408 3409 """ 3410 Remove a single mirror destination from a running pattern. 3411 3412 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 3413 ignored. Idempotent: silently does nothing if the destination is not 3414 currently mirrored. The change applies on the next cycle rebuild. 3415 """ 3416 3417 if name not in self._running_patterns: 3418 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3419 3420 resolved_channel = self._resolve_channel(channel) 3421 prefix = (device, resolved_channel) 3422 3423 pattern = self._running_patterns[name] 3424 3425 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 3426 if len(filtered) != len(pattern.mirrors): 3427 pattern.mirrors[:] = filtered 3428 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 3429 else: 3430 logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors") 3431 3432 def unmirror_all (self, name: str) -> None: 3433 3434 """ 3435 Remove every mirror destination from a running pattern. 3436 """ 3437 3438 if name not in self._running_patterns: 3439 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3440 3441 pattern = self._running_patterns[name] 3442 3443 if pattern.mirrors: 3444 pattern.mirrors.clear() 3445 logger.info(f"All mirrors cleared on pattern: {name}") 3446 3447 def tweak (self, name: str, **kwargs: typing.Any) -> None: 3448 3449 """Override parameters for a running pattern. 3450 3451 Values set here are available inside the pattern's builder 3452 function via ``p.param()``. They persist across rebuilds 3453 until explicitly changed or cleared. Changes take effect 3454 on the next rebuild cycle. 3455 3456 Parameters: 3457 name: The function name of the pattern. 3458 **kwargs: Parameter names and their new values. 3459 3460 Example (from the live REPL):: 3461 3462 composition.tweak("bass", pitches=[48, 52, 55, 60]) 3463 """ 3464 3465 if name not in self._running_patterns: 3466 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3467 3468 self._running_patterns[name]._tweaks.update(kwargs) 3469 logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}") 3470 3471 def clear_tweak (self, name: str, *param_names: str) -> None: 3472 3473 """Remove tweaked parameters from a running pattern. 3474 3475 If no parameter names are given, all tweaks for the pattern 3476 are cleared and every ``p.param()`` call reverts to its 3477 default. 3478 3479 Parameters: 3480 name: The function name of the pattern. 3481 *param_names: Specific parameter names to clear. If 3482 omitted, all tweaks are removed. 3483 """ 3484 3485 if name not in self._running_patterns: 3486 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3487 3488 if not param_names: 3489 self._running_patterns[name]._tweaks.clear() 3490 logger.info(f"Cleared all tweaks for pattern '{name}'") 3491 else: 3492 for param_name in param_names: 3493 self._running_patterns[name]._tweaks.pop(param_name, None) 3494 logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}") 3495 3496 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 3497 3498 """Return a copy of the current tweaks for a running pattern. 3499 3500 Parameters: 3501 name: The function name of the pattern. 3502 """ 3503 3504 if name not in self._running_patterns: 3505 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3506 3507 return dict(self._running_patterns[name]._tweaks) 3508 3509 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 3510 3511 """ 3512 Register a custom function to run on a repeating beat-based cycle. 3513 3514 Subsequence automatically runs synchronous functions in a thread pool 3515 so they don't block the timing-critical MIDI clock. Async functions 3516 are run directly on the event loop. 3517 3518 Parameters: 3519 fn: The function to call. 3520 cycle_beats: How often to call it (e.g., 4 = every bar). 3521 reschedule_lookahead: How far in advance to schedule the next call. 3522 wait_for_initial: If True, run the function once during startup 3523 and wait for it to complete before playback begins. This 3524 ensures ``composition.data`` is populated before patterns 3525 first build. Implies ``defer=True`` for the repeating 3526 schedule. 3527 defer: If True, skip the pulse-0 fire and defer the first 3528 repeating call to just before the second cycle boundary. 3529 3530 Raises: 3531 RuntimeError: If called after ``play()`` has started — scheduled 3532 tasks register at startup, so a late registration would be 3533 silently ignored otherwise. 3534 """ 3535 3536 if self._sequencer.running: 3537 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 3538 3539 self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer)) 3540 3541 def form ( 3542 self, 3543 sections: typing.Union[ 3544 typing.List[typing.Tuple[str, int]], 3545 typing.Iterator[typing.Tuple[str, int]], 3546 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 3547 ], 3548 loop: bool = False, 3549 start: typing.Optional[str] = None 3550 ) -> None: 3551 3552 """ 3553 Define the structure (sections) of the composition. 3554 3555 You can define form in three ways: 3556 1. **Graph (Dict)**: Dynamic transitions based on weights. 3557 2. **Sequence (List)**: A fixed order of sections. 3558 3. **Generator**: A Python generator that yields `(name, bars)` pairs. 3559 3560 Parameters: 3561 sections: The form definition (Dict, List, or Generator). 3562 loop: Whether to cycle back to the start (List mode only). 3563 start: The section to start with (Graph mode only). 3564 3565 Example: 3566 ```python 3567 # A simple pop structure 3568 comp.form([ 3569 ("verse", 8), 3570 ("chorus", 8), 3571 ("verse", 8), 3572 ("chorus", 16) 3573 ]) 3574 ``` 3575 """ 3576 3577 # Seed FormState at form() time (per-call salt) so build-time walks — 3578 # the frozen clones form_freeze will take — are deterministic without 3579 # play(); the play-time stream is re-dealt name-keyed in _run(). 3580 self._form_count += 1 3581 3582 self._form_state = subsequence.form_state.FormState( 3583 sections, 3584 loop = loop, 3585 start = start, 3586 rng = self._stream(f"form:{self._form_count}") 3587 ) 3588 3589 @staticmethod 3590 def _resolve_length ( 3591 beats: typing.Optional[float], 3592 bars: typing.Optional[float], 3593 steps: typing.Optional[float], 3594 step_duration: typing.Optional[float], 3595 default: float = 4.0, 3596 beats_per_bar: int = 4, 3597 ) -> typing.Tuple[float, int]: 3598 3599 """ 3600 Resolve the beat_length and default_grid from the duration parameters. 3601 3602 Two modes: 3603 - **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``. 3604 ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats. 3605 - **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``. 3606 ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats. 3607 3608 Constraints: 3609 - ``beats`` and ``bars`` are mutually exclusive. 3610 - ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``. 3611 - ``steps`` cannot be combined with ``beats`` or ``bars``. 3612 3613 Returns: 3614 (beat_length, default_grid) — beat_length in beats (quarter notes); 3615 default_grid the number of grid steps (16th-notes in beat mode, or the 3616 explicit ``steps`` value directly in step mode). 3617 """ 3618 3619 if beats is not None and bars is not None: 3620 raise ValueError("Specify only one of beats= or bars=") 3621 3622 if steps is not None and (beats is not None or bars is not None): 3623 raise ValueError("steps= cannot be combined with beats= or bars=") 3624 3625 if step_duration is not None and steps is None: 3626 raise ValueError("step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)") 3627 3628 if steps is not None: 3629 if step_duration is None: 3630 raise ValueError("steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)") 3631 return steps * step_duration, int(steps) 3632 3633 if bars is not None: 3634 raw = bars * beats_per_bar 3635 elif beats is not None: 3636 raw = beats 3637 else: 3638 raw = default 3639 3640 return raw, round(raw / subsequence.constants.durations.SIXTEENTH) 3641 3642 def pattern ( 3643 self, 3644 channel: int, 3645 beats: typing.Optional[float] = None, 3646 bars: typing.Optional[float] = None, 3647 steps: typing.Optional[float] = None, 3648 step_duration: typing.Optional[float] = None, 3649 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 3650 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 3651 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 3652 reschedule_lookahead: float = 1, 3653 voice_leading: bool = False, 3654 device: subsequence.midi_utils.DeviceId = None, 3655 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3656 ) -> typing.Callable: 3657 3658 """ 3659 Register a function as a repeating MIDI pattern. 3660 3661 The decorated function will be called once per cycle to 'rebuild' its 3662 content. This allows for generative logic that evolves over time. 3663 3664 Two ways to specify pattern length: 3665 3666 - **Duration mode** (default): use ``beats=`` or ``bars=``. 3667 The grid defaults to sixteenth-note resolution. 3668 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 3669 The grid equals the step count, so ``p.hit_steps()`` indices map 3670 directly to steps. 3671 3672 Parameters: 3673 channel: MIDI channel. By default uses 1-based numbering (1-16). 3674 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 3675 0-based numbering (0-15), matching the raw MIDI protocol, instead. 3676 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 3677 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 3678 steps: Step count for step mode. Requires ``step_duration=``. 3679 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 3680 Requires ``steps=``. 3681 drum_note_map: Optional mapping for drum instruments. 3682 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 3683 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 3684 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 3685 14-bit parameter numbers (0–16383). Enables string-based names 3686 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 3687 device-specific dictionary (e.g. Sequential Take 5's 3688 ``Osc1FreqFine`` → 9). 3689 reschedule_lookahead: Beats in advance to compute the next cycle. 3690 voice_leading: If True, chords in this pattern will automatically 3691 use inversions that minimize voice movement. 3692 mirrors: Optional list of additional ``(device, channel)`` destinations 3693 to duplicate every event from this pattern onto. Notes, CCs, pitch 3694 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 3695 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 3696 ``device`` is the integer index returned by ``midi_output()`` (0 = 3697 primary). ``channel`` follows this composition's channel-numbering 3698 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 3699 3700 Example: 3701 ```python 3702 @comp.pattern(channel=1, beats=4) 3703 def chords (p): 3704 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 3705 3706 @comp.pattern(channel=1, bars=2) 3707 def long_phrase (p): 3708 ... 3709 3710 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 3711 def riff (p): 3712 p.sequence(steps=[0, 1, 3, 5], pitches=60) 3713 ``` 3714 """ 3715 3716 channel = self._resolve_channel(channel) 3717 3718 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 3719 3720 # Resolve device string name to index if possible now; otherwise store 3721 # the raw DeviceId and resolve it in _run() once all devices are open. 3722 resolved_device: subsequence.midi_utils.DeviceId = device 3723 3724 # Mirror-to-self check is only reliable when the primary device is a 3725 # concrete integer at decoration time. ``None`` resolves to device 0 3726 # downstream, so we treat it as 0 here too. Strings are deferred to 3727 # ``_run()`` and we skip the check for them. 3728 primary: typing.Optional[typing.Tuple[int, int]] 3729 if isinstance(resolved_device, str): 3730 primary = None 3731 else: 3732 primary = (resolved_device if resolved_device is not None else 0, channel) 3733 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 3734 3735 def decorator (fn: typing.Callable) -> typing.Callable: 3736 3737 """ 3738 Wrap the builder function and register it as a pending pattern. 3739 During live sessions, hot-swap an existing pattern's builder instead. 3740 """ 3741 3742 # Record this declaration so the live-reload deletion diff knows the 3743 # pattern is still present in the source (see _apply_source_async). 3744 self._declared_names.add(fn.__name__) 3745 3746 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 3747 if self._is_live and fn.__name__ in self._running_patterns: 3748 running = self._running_patterns[fn.__name__] 3749 running._builder_fn = fn 3750 running._wants_chord = _fn_has_parameter(fn, "chord") 3751 logger.info(f"Hot-swapped pattern: {fn.__name__}") 3752 return fn 3753 3754 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 3755 # duplicate means two scheduled copies sharing one stream with 3756 # only one reachable by name. Warn loudly at registration. 3757 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 3758 logger.warning( 3759 f"Duplicate pattern name '{fn.__name__}': both copies will be " 3760 f"scheduled, they share one seeded stream, and only one is " 3761 f"reachable by name — rename one of them." 3762 ) 3763 3764 pending = _PendingPattern( 3765 builder_fn = fn, 3766 channel = channel, # already resolved to 0-indexed 3767 length = beat_length, 3768 default_grid = default_grid, 3769 drum_note_map = drum_note_map, 3770 cc_name_map = cc_name_map, 3771 nrpn_name_map = nrpn_name_map, 3772 reschedule_lookahead = reschedule_lookahead, 3773 voice_leading = voice_leading, 3774 # For int/None: resolve immediately. For str: store 0 as 3775 # placeholder; _resolve_pending_devices() fixes it in _run(). 3776 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 3777 raw_device = resolved_device, 3778 mirrors = resolved_mirrors, 3779 ) 3780 3781 self._pending_patterns.append(pending) 3782 3783 return fn 3784 3785 return decorator 3786 3787 def layer ( 3788 self, 3789 *builder_fns: typing.Callable, 3790 channel: int, 3791 beats: typing.Optional[float] = None, 3792 bars: typing.Optional[float] = None, 3793 steps: typing.Optional[float] = None, 3794 step_duration: typing.Optional[float] = None, 3795 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 3796 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 3797 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 3798 reschedule_lookahead: float = 1, 3799 voice_leading: bool = False, 3800 device: subsequence.midi_utils.DeviceId = None, 3801 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3802 ) -> None: 3803 3804 """ 3805 Combine multiple functions into a single MIDI pattern. 3806 3807 This is useful for composing complex patterns out of reusable 3808 building blocks (e.g., a 'kick' function and a 'snare' function). 3809 3810 See ``pattern()`` for the full description of ``beats``, ``bars``, 3811 ``steps``, and ``step_duration``. 3812 3813 Parameters: 3814 builder_fns: One or more pattern builder functions. 3815 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 3816 beats: Duration in beats (quarter notes). 3817 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 3818 steps: Step count for step mode. Requires ``step_duration=``. 3819 step_duration: Duration of one step in beats. Requires ``steps=``. 3820 drum_note_map: Optional mapping for drum instruments. 3821 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 3822 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 3823 parameter numbers. 3824 reschedule_lookahead: Beats in advance to compute the next cycle. 3825 voice_leading: If True, chords use smooth voice leading. 3826 mirrors: Optional list of additional ``(device, channel)`` destinations 3827 to duplicate every event onto. See ``pattern()`` for details. 3828 """ 3829 3830 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 3831 3832 # Resolve channel up-front so the mirror-to-self check has the canonical 3833 # primary form to compare against. 3834 resolved_channel = self._resolve_channel(channel) 3835 3836 # See pattern() for the same comment about None / str handling. 3837 primary: typing.Optional[typing.Tuple[int, int]] 3838 if isinstance(device, str): 3839 primary = None 3840 else: 3841 primary = (device if device is not None else 0, resolved_channel) 3842 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 3843 3844 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 3845 3846 if wants_chord: 3847 3848 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 3849 3850 for fn in builder_fns: 3851 if _fn_has_parameter(fn, "chord"): 3852 fn(p, chord) 3853 else: 3854 fn(p) 3855 3856 else: 3857 3858 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 3859 3860 for fn in builder_fns: 3861 fn(p) 3862 3863 # Give the merged builder a stable, unique name derived from its 3864 # components so multiple layer() calls don't all register under 3865 # "merged_builder" and collide in _running_patterns (which made 3866 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 3867 # appear in a Python identifier, so this never clashes with a real 3868 # pattern function's name. 3869 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 3870 merged_name = base_name 3871 suffix = 2 3872 3873 # Two layers with the same components (e.g. on different saves of a 3874 # live file) must map to the same names pass-over-pass, while two 3875 # DIFFERENT layers sharing components in one pass must not collide. 3876 while merged_name in self._declared_names: 3877 merged_name = f"{base_name}#{suffix}" 3878 suffix += 1 3879 3880 merged_builder.__name__ = merged_name 3881 3882 # Record the declaration for the live-reload deletion diff, and hot-swap 3883 # in place when this layer is already running so a reload picks up edits 3884 # to the component functions without losing the pattern's cycle count, 3885 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 3886 self._declared_names.add(merged_builder.__name__) 3887 3888 if self._is_live and merged_builder.__name__ in self._running_patterns: 3889 running = self._running_patterns[merged_builder.__name__] 3890 running._builder_fn = merged_builder 3891 running._wants_chord = wants_chord 3892 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 3893 return 3894 3895 pending = _PendingPattern( 3896 builder_fn = merged_builder, 3897 channel = resolved_channel, # already resolved to 0-indexed above 3898 length = beat_length, 3899 default_grid = default_grid, 3900 drum_note_map = drum_note_map, 3901 cc_name_map = cc_name_map, 3902 nrpn_name_map = nrpn_name_map, 3903 reschedule_lookahead = reschedule_lookahead, 3904 voice_leading = voice_leading, 3905 mirrors = resolved_mirrors, 3906 device = 0 if (device is None or isinstance(device, str)) else device, 3907 raw_device = device, 3908 ) 3909 3910 self._pending_patterns.append(pending) 3911 3912 def chords ( 3913 self, 3914 *, 3915 channel: int, 3916 progression: subsequence.progressions.ProgressionSource, 3917 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 3918 bars: typing.Optional[float] = None, 3919 beats: typing.Optional[float] = None, 3920 voicing: subsequence.progressions.VoicingSpec = (3, 4), 3921 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 3922 detached: typing.Optional[float] = None, 3923 root: int = 60, 3924 key: typing.Optional[str] = None, 3925 seed: typing.Optional[int] = None, 3926 device: subsequence.midi_utils.DeviceId = None, 3927 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3928 ) -> subsequence.progressions.Progression: 3929 3930 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 3931 3932 The one-call form of ``p.progression()`` — it registers a pattern on 3933 *channel* that plays *progression* across *bars* (or *beats*), each chord 3934 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 3935 the chords change). It needs no ``composition.harmony()`` call and, with an 3936 explicit chord list or a ``key=``, no composition key either — so a 3937 drums-plus-one-chord-part sketch stays simple. 3938 3939 The progression is realised once, up front, and the same timeline plays every 3940 cycle (a stable phrase). That timeline is returned so you can see exactly what 3941 was chosen — ``print(comp.chords(...))``. 3942 3943 Parameters: 3944 channel: MIDI channel for the chord part. 3945 progression: A chord-graph style name to generate from, or an explicit list 3946 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 3947 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 3948 or ``between(low, high, step=...)``. See ``p.progression()``. 3949 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 3950 composition's time signature. 3951 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 3952 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 3953 detached: Beats of silence before each next chord (``duration = length - detached``). 3954 root: MIDI root the voicings are centred on (e.g. 48 = C3). 3955 key: Key for a generated progression; defaults to the composition key. 3956 seed: Seed for the (otherwise fixed) realisation; defaults to the 3957 composition seed, so the part is reproducible. 3958 device: Optional output-device override. 3959 mirrors: Optional additional ``(device, channel)`` destinations. 3960 3961 Returns: 3962 The realised :class:`~subsequence.progressions.Progression`. 3963 """ 3964 3965 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 3966 resolved_channel = self._resolve_channel(channel) 3967 resolved_key = key if key is not None else self.key 3968 3969 rng = random.Random(seed if seed is not None else self._seed) 3970 timeline = subsequence.progressions.realize( 3971 source = progression, 3972 harmonic_rhythm = harmonic_rhythm, 3973 key = resolved_key, 3974 length = beat_length, 3975 rng = rng, 3976 scale = self.scale or "ionian", 3977 ) 3978 3979 captured_root = root 3980 captured_velocity = velocity 3981 captured_detached = detached 3982 captured_voicing = voicing 3983 3984 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 3985 3986 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 3987 3988 for chord, start, length in timeline: 3989 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 3990 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 3991 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 3992 3993 # Unique, stable name so multiple chord parts don't collide in 3994 # _running_patterns — including two parts on the SAME channel, which 3995 # get a deterministic #2/#3 suffix in declaration order. 3996 base_name = f"chords@ch{resolved_channel}" 3997 chords_name = base_name 3998 suffix = 2 3999 4000 while chords_name in self._declared_names: 4001 chords_name = f"{base_name}#{suffix}" 4002 suffix += 1 4003 4004 chords_builder.__name__ = chords_name 4005 self._declared_names.add(chords_name) 4006 4007 primary: typing.Optional[typing.Tuple[int, int]] 4008 if isinstance(device, str): 4009 primary = None 4010 else: 4011 primary = (device if device is not None else 0, resolved_channel) 4012 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4013 4014 self._declared_names.add(chords_builder.__name__) 4015 4016 if self._is_live and chords_builder.__name__ in self._running_patterns: 4017 running = self._running_patterns[chords_builder.__name__] 4018 running._builder_fn = chords_builder 4019 running._wants_chord = False 4020 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 4021 return timeline 4022 4023 pending = _PendingPattern( 4024 builder_fn = chords_builder, 4025 channel = resolved_channel, 4026 length = beat_length, 4027 default_grid = default_grid, 4028 drum_note_map = None, 4029 reschedule_lookahead = 1, 4030 voice_leading = False, 4031 mirrors = resolved_mirrors, 4032 device = 0 if (device is None or isinstance(device, str)) else device, 4033 raw_device = device, 4034 ) 4035 self._pending_patterns.append(pending) 4036 return timeline 4037 4038 def phrase_part ( 4039 self, 4040 *, 4041 channel: int, 4042 part: typing.Optional[str] = None, 4043 root: int = 60, 4044 bars: typing.Optional[float] = None, 4045 beats: typing.Optional[float] = None, 4046 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 4047 fit: typing.Optional[float] = None, 4048 device: subsequence.midi_utils.DeviceId = None, 4049 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4050 ) -> None: 4051 4052 """Declare a part that plays each section's bound Motif/Phrase. 4053 4054 The one-call consumer for :meth:`section_motifs` — it registers a 4055 pattern on *channel* that walks whatever value is bound to the 4056 current section for *part* (stateless position from the cycle 4057 counter, via ``p.phrase()``). A section with no binding for the 4058 part is **silent** for that part — bind material or don't; no 4059 fallback guessing. 4060 4061 Parameters: 4062 channel: MIDI channel for the part. 4063 part: The part label to read from the registry (``None`` = the 4064 unlabelled binding). 4065 root: Register anchor for degree resolution. 4066 bars / beats: Cycle length of the part (defaults to 4 beats); 4067 the phrase is sliced one cycle window at a time. 4068 velocity: Optional override applied to every note. 4069 fit: Passed through (active with the melody engine stage). 4070 device: Optional output-device override. 4071 mirrors: Optional additional ``(device, channel)`` destinations. 4072 4073 Example:: 4074 4075 composition.section_motifs("verse", verse_line, part="lead") 4076 composition.section_motifs("chorus", chorus_line, part="lead") 4077 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 4078 """ 4079 4080 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 4081 resolved_channel = self._resolve_channel(channel) 4082 4083 captured_part = part 4084 captured_root = root 4085 captured_velocity = velocity 4086 captured_fit = fit 4087 4088 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 4089 4090 """Walk the current section's bound value (silent when unbound).""" 4091 4092 value = p.section_motif(captured_part) 4093 4094 if value is None: 4095 return # unbound section: silence for this part, by design 4096 4097 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 4098 4099 # Unique, stable name so multiple phrase parts don't collide — 4100 # including two parts on the SAME channel (deterministic #2/#3 4101 # suffixes in declaration order, the chords() convention). 4102 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 4103 phrase_name = base_name 4104 suffix = 2 4105 4106 while phrase_name in self._declared_names: 4107 phrase_name = f"{base_name}#{suffix}" 4108 suffix += 1 4109 4110 phrase_builder.__name__ = phrase_name 4111 self._declared_names.add(phrase_name) 4112 4113 primary: typing.Optional[typing.Tuple[int, int]] 4114 if isinstance(device, str): 4115 primary = None 4116 else: 4117 primary = (device if device is not None else 0, resolved_channel) 4118 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4119 4120 if self._is_live and phrase_builder.__name__ in self._running_patterns: 4121 running = self._running_patterns[phrase_builder.__name__] 4122 running._builder_fn = phrase_builder 4123 running._wants_chord = False 4124 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 4125 return 4126 4127 pending = _PendingPattern( 4128 builder_fn = phrase_builder, 4129 channel = resolved_channel, 4130 length = beat_length, 4131 default_grid = default_grid, 4132 drum_note_map = None, 4133 reschedule_lookahead = 1, 4134 voice_leading = False, 4135 mirrors = resolved_mirrors, 4136 device = 0 if (device is None or isinstance(device, str)) else device, 4137 raw_device = device, 4138 ) 4139 self._pending_patterns.append(pending) 4140 4141 def trigger ( 4142 self, 4143 fn: typing.Callable, 4144 channel: int, 4145 beats: typing.Optional[float] = None, 4146 bars: typing.Optional[float] = None, 4147 steps: typing.Optional[float] = None, 4148 step_duration: typing.Optional[float] = None, 4149 quantize: float = 0, 4150 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4151 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4152 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4153 chord: bool = False, 4154 device: subsequence.midi_utils.DeviceId = None, 4155 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4156 ) -> None: 4157 4158 """ 4159 Trigger a one-shot pattern immediately or on a quantized boundary. 4160 4161 This is useful for real-time response to sensors, OSC messages, or other 4162 external events. The builder function is called immediately with a fresh 4163 PatternBuilder, and the generated events are injected into the queue at 4164 the specified quantize boundary. 4165 4166 The builder function has the same API as a ``@composition.pattern`` 4167 decorated function and can use all PatternBuilder methods: ``p.note()``, 4168 ``p.euclidean()``, ``p.arpeggio()``, and so on. 4169 4170 See ``pattern()`` for the full description of ``beats``, ``bars``, 4171 ``steps``, and ``step_duration``. Default is 1 beat. 4172 4173 Parameters: 4174 fn: The pattern builder function (same signature as ``@comp.pattern``). 4175 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4176 beats: Duration in beats (quarter notes, default 1). 4177 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4178 steps: Step count for step mode. Requires ``step_duration=``. 4179 step_duration: Duration of one step in beats. Requires ``steps=``. 4180 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 4181 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 4182 constants from ``subsequence.constants.durations``. 4183 drum_note_map: Optional drum name mapping for this pattern. 4184 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4185 nrpn_name_map: Optional mapping of NRPN parameter names to 4186 14-bit parameter numbers. 4187 chord: If ``True``, the builder function receives the current chord as 4188 a second parameter (same as ``@composition.pattern``). 4189 mirrors: Optional list of additional ``(device, channel)`` destinations 4190 to fire this one-shot onto in parallel with the primary destination. 4191 4192 Example: 4193 ```python 4194 # Immediate single note (channels are 1-16 by default) 4195 composition.trigger( 4196 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 4197 channel=1 4198 ) 4199 4200 # Quantized fill (next bar) — channel 10 is the GM drum channel 4201 import subsequence.constants.durations as dur 4202 composition.trigger( 4203 lambda p: p.euclidean("snare", pulses=7, velocity=90), 4204 channel=10, 4205 drum_note_map=gm_drums.GM_DRUM_MAP, 4206 quantize=dur.WHOLE 4207 ) 4208 4209 # With chord context — the builder receives the chord as a second 4210 # argument when chord=True. 4211 composition.trigger( 4212 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 4213 channel=1, 4214 quantize=dur.QUARTER, 4215 chord=True 4216 ) 4217 ``` 4218 """ 4219 4220 # Resolve channel numbering 4221 resolved_channel = self._resolve_channel(channel) 4222 4223 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 4224 4225 # Resolve device index — for trigger() this is always concrete by call time, 4226 # so the mirror-to-self check has the full primary tuple available. 4227 resolved_device_idx = self._resolve_device_id(device) 4228 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 4229 4230 # Create a temporary Pattern 4231 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 4232 4233 # Create a PatternBuilder 4234 builder = subsequence.pattern_builder.PatternBuilder( 4235 pattern=pattern, 4236 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 4237 drum_note_map=drum_note_map, 4238 cc_name_map=cc_name_map, 4239 nrpn_name_map=nrpn_name_map, 4240 section=self._form_state.get_section_info() if self._form_state else None, 4241 bar=self._builder_bar, 4242 conductor=self.conductor, 4243 rng=random.Random(), # Fresh random state for each trigger 4244 tweaks={}, 4245 default_grid=default_grid, 4246 data=self.data, 4247 held_notes=self._sequencer._held_notes 4248 ) 4249 4250 # Call the builder function 4251 try: 4252 4253 current_chord = self.current_chord() if chord else None 4254 4255 if current_chord is not None: 4256 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 4257 fn(builder, injected) 4258 4259 else: 4260 fn(builder) 4261 4262 except Exception: 4263 logger.exception("Error in trigger builder — pattern will be silent") 4264 return 4265 4266 # Calculate the start pulse based on quantize 4267 current_pulse = self._sequencer.pulse_count 4268 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 4269 4270 if quantize == 0: 4271 # Immediate: use current pulse 4272 start_pulse = current_pulse 4273 4274 else: 4275 # Quantize to the next multiple of (quantize * pulses_per_beat) 4276 quantize_pulses = int(quantize * pulses_per_beat) 4277 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 4278 4279 # Schedule the pattern for one-shot execution 4280 try: 4281 # Probe only: raises RuntimeError when not on the event loop. 4282 asyncio.get_running_loop() 4283 asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) 4284 4285 except RuntimeError: 4286 # Not on the event loop — hand the coroutine to the loop thread. 4287 if self._sequencer._event_loop is not None: 4288 asyncio.run_coroutine_threadsafe( 4289 self._sequencer.schedule_pattern(pattern, start_pulse), 4290 loop=self._sequencer._event_loop 4291 ) 4292 else: 4293 logger.warning("trigger() called before playback started; pattern ignored") 4294 4295 @property 4296 def is_clock_following (self) -> bool: 4297 4298 """True if either the primary or any additional device is following external clock.""" 4299 4300 return self._clock_follow or any(cf for _, _, cf in self._additional_inputs) 4301 4302 4303 def play (self) -> None: 4304 4305 """ 4306 Start the composition. 4307 4308 This call blocks until the program is interrupted (e.g., via Ctrl+C). 4309 It initializes the MIDI hardware, launches the background sequencer, 4310 and begins playback. 4311 """ 4312 4313 try: 4314 asyncio.run(self._run()) 4315 4316 except KeyboardInterrupt: 4317 pass 4318 4319 4320 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 4321 4322 """Render the composition to a MIDI file without real-time playback. 4323 4324 Runs the sequencer as fast as possible (no timing delays) and stops 4325 when the first active limit is reached. The result is saved as a 4326 standard MIDI file that can be imported into any DAW. 4327 4328 All patterns, scheduled callbacks, and harmony logic run exactly as 4329 they would during live playback — BPM transitions, generative fills, 4330 and probabilistic gates all work in render mode. The only difference 4331 is that time is simulated rather than wall-clock driven. 4332 4333 Parameters: 4334 bars: Number of bars to render, or ``None`` for no bar limit 4335 (default ``None``). When both *bars* and *max_minutes* are 4336 active, playback stops at whichever limit is reached first. 4337 filename: Output MIDI filename (default ``"render.mid"``). 4338 max_minutes: Safety cap on the length of rendered MIDI in minutes 4339 (default ``60.0``). Pass ``None`` to disable the time 4340 cap — you must then provide an explicit *bars* value. 4341 4342 Raises: 4343 ValueError: If both *bars* and *max_minutes* are ``None``, which 4344 would produce an infinite render. 4345 4346 Examples: 4347 ```python 4348 # Default: renders up to 60 minutes of MIDI content. 4349 composition.render() 4350 4351 # Render exactly 64 bars (time cap still active as backstop). 4352 composition.render(bars=64, filename="demo.mid") 4353 4354 # Render up to 5 minutes of an infinite generative composition. 4355 composition.render(max_minutes=5, filename="five_min.mid") 4356 4357 # Remove the time cap — must supply bars instead. 4358 composition.render(bars=128, max_minutes=None, filename="long.mid") 4359 ``` 4360 """ 4361 4362 if bars is None and max_minutes is None: 4363 raise ValueError( 4364 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 4365 "Passing both as None would produce an infinite render." 4366 ) 4367 4368 self._sequencer.recording = True 4369 self._sequencer.record_filename = filename 4370 self._sequencer.render_mode = True 4371 self._sequencer.render_bars = bars if bars is not None else 0 4372 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 4373 asyncio.run(self._run()) 4374 4375 def _broadcast_osc_status (self, bar: int) -> None: 4376 4377 """ 4378 Send the per-bar OSC status snapshot: bar number, current tempo, 4379 and (when active) the current chord name and form section. 4380 """ 4381 4382 if self._osc_server: 4383 self._osc_server.send("/bar", bar) 4384 self._osc_server.send("/bpm", self._sequencer.current_bpm) 4385 4386 sounding = self.current_chord() 4387 if sounding is not None: 4388 self._osc_server.send("/chord", sounding.name()) 4389 4390 if self._form_state: 4391 info = self._form_state.get_section_info() 4392 if info: 4393 self._osc_server.send("/section", info.name) 4394 4395 async def _run (self) -> None: 4396 4397 """ 4398 Async entry point that schedules all patterns and runs the sequencer. 4399 """ 4400 4401 # 1. Pre-calculate MIDI input indices and configure sequencer clock follow. 4402 if self._input_device is not None: 4403 self._sequencer.input_device_name = self._input_device 4404 self._sequencer.clock_follow = self._clock_follow 4405 self._sequencer.clock_device_idx = 0 4406 4407 if not self._clock_follow: 4408 # Find first additional input that wants to be the clock master. 4409 for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1): 4410 if cf: 4411 self._sequencer.clock_follow = True 4412 self._sequencer.clock_device_idx = idx 4413 break 4414 4415 # Populate input device name mapping early (before opening ports) so we can 4416 # resolve CC mappings to integer device indices immediately. 4417 if self._sequencer.input_device_name: 4418 self._input_device_names[self._sequencer.input_device_name] = 0 4419 if self._input_device_alias is not None: 4420 self._input_device_names[self._input_device_alias] = 0 4421 4422 for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1): 4423 self._input_device_names[dev_name] = idx 4424 if alias: 4425 self._input_device_names[alias] = idx 4426 4427 # 2. Pre-calculate output device names. 4428 if self._sequencer.output_device_name: 4429 self._output_device_names[self._sequencer.output_device_name] = 0 4430 # Primary device (index 0) is open by now (_init_midi_output ran in 4431 # the Sequencer constructor), so its latency can be set safely here. 4432 if self._output_latency_ms: 4433 self._sequencer.set_device_latency(0, self._output_latency_ms) 4434 4435 # 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the 4436 # input-names map is fully populated above, and the callback thread needs 4437 # integer indices as soon as ports open. OUTPUT names (cc_forward 4438 # output_device=, pattern device=) resolve after the additional outputs 4439 # are opened below; resolving them here matched against a map containing 4440 # only the primary and silently routed everything to device 0. 4441 for mapping in self._cc_mappings: 4442 raw = mapping.get('input_device') 4443 if isinstance(raw, str): 4444 mapping['input_device'] = self._resolve_input_device_id(raw) 4445 for fwd in self._cc_forwards: 4446 raw_in = fwd.get('input_device') 4447 if isinstance(raw_in, str): 4448 fwd['input_device'] = self._resolve_input_device_id(raw_in) 4449 4450 # 4. Share CC input mappings, forwards, and a reference to composition.data 4451 # with the sequencer BEFORE opening the ports. This ensures that any initial 4452 # messages in the OS buffer are correctly mapped as soon as the port opens. 4453 self._sequencer.cc_mappings = self._cc_mappings 4454 self._sequencer.cc_forwards = self._cc_forwards 4455 self._sequencer._composition_data = self.data 4456 4457 # Held-note input: create the tracker and resolve its channel/device 4458 # filter so the callback thread can buffer matching note events. 4459 if self._note_input is not None: 4460 if self._input_device is None and not self._additional_inputs: 4461 raise RuntimeError("note_input() requires a MIDI input — call composition.midi_input(device) first") 4462 raw_dev = self._note_input.get('input_device') 4463 if isinstance(raw_dev, str): 4464 raw_dev = self._resolve_input_device_id(raw_dev) 4465 self._sequencer._note_input_channel = self._note_input['channel'] 4466 self._sequencer._note_input_device = raw_dev 4467 self._sequencer._held_notes = subsequence.held_notes.HeldNotes( 4468 release_ms = self._note_input['release_ms'], 4469 latch = self._note_input['latch'], 4470 ) 4471 4472 # 5. Open MIDI input ports early. Even without a deliberate sleep, opening 4473 # them before pattern building minimizes the window for missed messages. 4474 # Primary input 4475 self._sequencer._open_midi_inputs() 4476 4477 # Additional inputs 4478 for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1): 4479 # Use the pre-calculated index 4480 callback = self._sequencer._make_input_callback(idx) 4481 open_name, port = subsequence.midi_utils.select_input_device(dev_name, callback) 4482 if open_name and port is not None: 4483 self._sequencer.add_input_device(open_name, port) 4484 else: 4485 logger.warning(f"Could not open additional input device '{dev_name}'") 4486 4487 # 6. Open additional MIDI output devices. 4488 for out in self._additional_outputs: 4489 open_name, port = subsequence.midi_utils.select_output_device(out.device) 4490 if open_name and port is not None: 4491 idx = self._sequencer.add_output_device(open_name, port, out.latency_ms) 4492 self._output_device_names[open_name] = idx 4493 if out.alias is not None: 4494 self._output_device_names[out.alias] = idx 4495 else: 4496 logger.warning(f"Could not open additional output device '{out.device}'") 4497 4498 # Warn if latency compensation adds noticeable whole-rig delay: the 4499 # slowest device defines the alignment point, so every faster device is 4500 # delayed up to that amount and live-input feel suffers. 4501 self._warn_if_high_latency() 4502 4503 # Resolve any name-based output device IDs on patterns that may have been added 4504 # for additional output devices. 4505 self._resolve_pending_devices() 4506 4507 # Resolve cc_forward output-device names now that every output port and 4508 # alias is registered (resolving earlier silently routed to device 0). 4509 for fwd in self._cc_forwards: 4510 raw_out = fwd.get('output_device') 4511 if isinstance(raw_out, str): 4512 fwd['output_device'] = self._resolve_device_id(raw_out) 4513 4514 # Pass clock output flag (suppressed automatically when clock_follow=True). 4515 self._sequencer.clock_output = self._clock_output and not self.is_clock_following 4516 4517 # Create Ableton Link clock if comp.link() was called. 4518 if self._link_quantum is not None: 4519 self._sequencer._link_clock = subsequence.link_clock.LinkClock( 4520 bpm = self.bpm, 4521 quantum = self._link_quantum, 4522 loop = asyncio.get_running_loop(), 4523 ) 4524 4525 # Deal play-time streams. Every stream is NAME-keyed (crc32 of 4526 # "seed:name", see _stream_seed) rather than dealt from one master in 4527 # registration order: adding or removing one consumer can never shift 4528 # another's stream, and patterns added live derive identically in 4529 # _build_pattern_from_pending. When no seed is set, components keep 4530 # their own unseeded RNGs (existing behaviour). 4531 if self._seed is not None: 4532 4533 harmony_stream = self._stream("play:harmony") 4534 if self._harmonic_state is not None and harmony_stream is not None: 4535 self._harmonic_state.rng = harmony_stream 4536 4537 form_stream = self._stream("play:form") 4538 if self._form_state is not None and form_stream is not None: 4539 self._form_state._rng = form_stream 4540 4541 # The clocks fire BEFORE pattern rebuilds at the same pulse, and their 4542 # lookahead is RAISED to the maximum pattern lookahead (never patterns 4543 # clamped down): when a pattern rebuilds for its next cycle, the form 4544 # state and the harmony window already describe that cycle. 4545 bar_beats = float(self.time_signature[0]) 4546 4547 pattern_lookaheads = [pending.reschedule_lookahead for pending in self._pending_patterns] 4548 pattern_lookaheads += [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] 4549 max_pattern_lookahead = max(pattern_lookaheads, default = 1) 4550 4551 clock_lookahead = max(1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead)) 4552 4553 if clock_lookahead > bar_beats: 4554 logger.warning( 4555 "A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — " 4556 "the harmony/form clocks fire at most one bar ahead, so that pattern may " 4557 "rebuild before the window covers its cycle start.", 4558 clock_lookahead, bar_beats, 4559 ) 4560 clock_lookahead = bar_beats 4561 4562 # Minimum span >= maximum lookahead: the clock cannot prepare a chord 4563 # boundary that arrives sooner than it fires. Harmonic motion faster 4564 # than this floor stays available at the part level (p.progression), 4565 # where placement is not clock-bound. 4566 def _check_span_floor (progression: typing.Optional[Progression], label: str) -> None: 4567 if progression is None: 4568 return 4569 shortest = min(span.beats for span in progression.spans) 4570 if shortest < clock_lookahead - 1e-9: 4571 raise ValueError( 4572 f"{label}: shortest chord span ({shortest:g} beats) is below the clock " 4573 f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). " 4574 "Lengthen the span, lower the pattern lookaheads, or place fast harmony " 4575 "at the part level with p.progression()." 4576 ) 4577 4578 _check_span_floor(self._bound_progression, "harmony(progression=)") 4579 for section_name, section_progression in self._section_progressions.items(): 4580 _check_span_floor(section_progression, f"section_chords({section_name!r})") 4581 4582 # The form clock MUST be registered before the harmonic clock: same-pulse 4583 # fixed callbacks fire in registration order (and all fixed callbacks fire 4584 # before callback sequences), and on a section-boundary bar the harmonic 4585 # clock reads the current section (via _get_section_progression) to decide 4586 # whether to walk that section's chords. Registering harmony first would 4587 # make it read the OLD section on every boundary, shifting section_chords() 4588 # replays by one bar and bleeding them across sections. 4589 if self._form_state is not None: 4590 4591 await schedule_form( 4592 sequencer = self._sequencer, 4593 form_state = self._form_state, 4594 reschedule_lookahead = clock_lookahead 4595 ) 4596 4597 self._harmony_horizon.reset() 4598 4599 if self._harmonic_state is not None or self._bound_progression is not None or self._section_progressions: 4600 4601 def _get_section_progression () -> typing.Optional[typing.Tuple[str, int, typing.Optional[Progression]]]: 4602 """Return (section_name, section_index, Progression|None) for the current section, or None.""" 4603 if self._form_state is None: 4604 return None 4605 info = self._form_state.get_section_info() 4606 if info is None: 4607 return None 4608 prog = self._section_progressions.get(info.name) 4609 return (info.name, info.index, prog) 4610 4611 await schedule_harmonic_clock( 4612 sequencer = self._sequencer, 4613 get_harmonic_state = lambda: self._harmonic_state, 4614 horizon = self._harmony_horizon, 4615 bar_beats = bar_beats, 4616 cycle_beats = self._harmony_cycle_beats or 4, 4617 get_bound_progression = lambda: self._bound_progression, 4618 get_section_progression = _get_section_progression, 4619 get_pinned = self._pinned_chords.get, 4620 reschedule_lookahead = clock_lookahead, 4621 ) 4622 4623 # Bar counter - always active so p.bar is available to all builders. 4624 def _advance_builder_bar (pulse: int) -> None: 4625 self._builder_bar += 1 4626 4627 first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat) 4628 4629 await self._sequencer.schedule_callback_repeating( 4630 callback = _advance_builder_bar, 4631 interval_beats = self.time_signature[0], 4632 start_pulse = first_bar_pulse, 4633 reschedule_lookahead = 1 4634 ) 4635 4636 # Run wait_for_initial=True scheduled functions and block until all complete. 4637 # This ensures composition.data is populated before patterns build. 4638 initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial] 4639 4640 if initial_tasks: 4641 4642 names = ", ".join(getattr(t.fn, '__name__', repr(t.fn)) for t in initial_tasks) 4643 logger.info(f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}") 4644 4645 async def _run_initial (fn: typing.Callable) -> None: 4646 4647 accepts_ctx = _fn_has_parameter(fn, "p") 4648 ctx = ScheduleContext(cycle=0) 4649 4650 try: 4651 if inspect.iscoroutinefunction(fn): 4652 await (fn(ctx) if accepts_ctx else fn()) 4653 else: 4654 loop = asyncio.get_running_loop() 4655 call = (lambda: fn(ctx)) if accepts_ctx else fn 4656 await loop.run_in_executor(None, call) 4657 except Exception as exc: 4658 logger.warning(f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}") 4659 4660 await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks]) 4661 4662 for pending_task in self._pending_scheduled: 4663 4664 accepts_ctx = _fn_has_parameter(pending_task.fn, "p") 4665 4666 # A wait_for_initial task already ran once as cycle 0 (the blocking 4667 # pre-roll above), so its repeating wrapper starts at cycle 1 — keeping 4668 # ScheduleContext.cycle monotonic across the initial and repeating runs. 4669 wrapped = _make_safe_callback( 4670 pending_task.fn, 4671 accepts_context = accepts_ctx, 4672 start_cycle = 1 if pending_task.wait_for_initial else 0, 4673 ) 4674 4675 # wait_for_initial=True implies defer — no point firing at pulse 0 4676 # after the blocking run just completed. defer=True skips the 4677 # backshift fire so the first repeating call happens one full cycle 4678 # later. 4679 if pending_task.wait_for_initial or pending_task.defer: 4680 start_pulse = int(pending_task.cycle_beats * self._sequencer.pulses_per_beat) 4681 else: 4682 start_pulse = 0 4683 4684 await self._sequencer.schedule_callback_repeating( 4685 callback = wrapped, 4686 interval_beats = pending_task.cycle_beats, 4687 start_pulse = start_pulse, 4688 reschedule_lookahead = pending_task.reschedule_lookahead 4689 ) 4690 4691 # Build Pattern objects from pending registrations. 4692 patterns: typing.List[subsequence.pattern.Pattern] = [] 4693 4694 for i, pending in enumerate(self._pending_patterns): 4695 4696 pattern = self._build_pattern_from_pending(pending) 4697 patterns.append(pattern) 4698 4699 await schedule_patterns( 4700 sequencer = self._sequencer, 4701 patterns = patterns, 4702 start_pulse = 0 4703 ) 4704 4705 # Populate the running patterns dict for live hot-swap and mute/unmute. 4706 for i, pending in enumerate(self._pending_patterns): 4707 name = pending.builder_fn.__name__ 4708 self._running_patterns[name] = patterns[i] 4709 4710 # Everything pending is running now; drop the declarations so a later 4711 # live reload cannot graduate stale copies. 4712 self._pending_patterns = [] 4713 4714 if self._display is not None and not self._sequencer.render_mode: 4715 self._display.start() 4716 self._sequencer.on_event("bar", self._display.update) 4717 self._sequencer.on_event("beat", self._display.update) 4718 4719 if self._live_server is not None: 4720 await self._live_server.start() 4721 4722 if self._osc_server is not None: 4723 await self._osc_server.start() 4724 self._sequencer.osc_server = self._osc_server 4725 self._sequencer.on_event("bar", self._broadcast_osc_status) 4726 4727 # Start keystroke listener if hotkeys are enabled and not in render mode. 4728 if self._hotkeys_enabled and not self._sequencer.render_mode: 4729 self._keystroke_listener = subsequence.keystroke.KeystrokeListener() 4730 self._keystroke_listener.start() 4731 4732 if self._keystroke_listener.active: 4733 # Listener started successfully — register the bar handler 4734 # and show all bindings so the user knows what's available. 4735 self._sequencer.on_event("bar", self._process_hotkeys) 4736 self._list_hotkeys() 4737 # If not active, KeystrokeListener.start() already logged a warning. 4738 4739 if self._web_ui_enabled and not self._sequencer.render_mode: 4740 self._web_ui_server = subsequence.web_ui.WebUI(self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host) 4741 self._web_ui_server.start() 4742 4743 try: 4744 await run_until_stopped(self._sequencer) 4745 finally: 4746 # Tear down every service even if run_until_stopped (or an earlier 4747 # stop) raised, and guard each individually, so one failure can't 4748 # strand the rest — most importantly the keystroke listener's 4749 # terminal restore. 4750 if self._web_ui_server is not None: 4751 try: 4752 self._web_ui_server.stop() 4753 except Exception: 4754 logger.exception("Error stopping web UI") 4755 4756 if self._live_server is not None: 4757 try: 4758 await self._live_server.stop() 4759 except Exception: 4760 logger.exception("Error stopping live server") 4761 4762 if self._live_reloader is not None: 4763 try: 4764 self._live_reloader.stop() 4765 except Exception: 4766 logger.exception("Error stopping live reloader") 4767 4768 if self._osc_server is not None: 4769 try: 4770 await self._osc_server.stop() 4771 except Exception: 4772 logger.exception("Error stopping OSC server") 4773 self._sequencer.osc_server = None 4774 4775 if self._display is not None: 4776 try: 4777 self._display.stop() 4778 except Exception: 4779 logger.exception("Error stopping display") 4780 4781 if self._keystroke_listener is not None: 4782 try: 4783 self._keystroke_listener.stop() 4784 except Exception: 4785 logger.exception("Error stopping keystroke listener") 4786 self._keystroke_listener = None 4787 4788 def _build_pattern_from_pending (self, pending: _PendingPattern, start_pulse: int = 0) -> subsequence.pattern.Pattern: 4789 4790 """ 4791 Create a Pattern from a pending registration using a temporary subclass. 4792 4793 The pattern's play stream is dealt here, keyed by NAME (crc32 of 4794 "seed:name" plus any reroll nonce), so registration order is 4795 irrelevant and a pattern added live gets exactly the stream it would 4796 have had at startup. ``start_pulse`` anchors the first cycle on the 4797 beat axis so the initial build reads the harmony window at the right 4798 place (the sequencer keeps the anchor current on every reschedule). 4799 """ 4800 4801 composition_ref = self 4802 rng = self._stream(pending.builder_fn.__name__) 4803 4804 class _DecoratorPattern (subsequence.pattern.Pattern): 4805 4806 """ 4807 Pattern subclass that delegates to a builder function on each reschedule. 4808 """ 4809 4810 def __init__ (self, pending: _PendingPattern, pattern_rng: typing.Optional[random.Random] = None) -> None: 4811 4812 """ 4813 Initialize the decorator pattern from pending registration details. 4814 """ 4815 4816 super().__init__( 4817 channel = pending.channel, 4818 length = pending.length, 4819 reschedule_lookahead = pending.reschedule_lookahead, 4820 device = pending.device, 4821 mirrors = pending.mirrors, 4822 ) 4823 4824 self._builder_fn = pending.builder_fn 4825 self._drum_note_map = pending.drum_note_map 4826 self._cc_name_map = pending.cc_name_map 4827 self._nrpn_name_map = pending.nrpn_name_map 4828 self._default_grid: int = pending.default_grid 4829 self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord") 4830 self._cycle_count = 0 4831 self._rng = pattern_rng 4832 self._muted = False 4833 self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = ( 4834 subsequence.voicings.VoiceLeadingState() if pending.voice_leading else None 4835 ) 4836 self._tweaks: typing.Dict[str, typing.Any] = {} 4837 4838 # Anchor of the cycle being built, on the absolute pulse axis. 4839 # The sequencer updates this on every reschedule; the initial 4840 # value is the pattern's first scheduled start. 4841 self._cycle_start_pulse = start_pulse 4842 4843 self._rebuild() 4844 4845 def _rebuild (self) -> None: 4846 4847 """ 4848 Clear steps and call the builder function to repopulate. 4849 """ 4850 4851 self.steps = {} 4852 self.cc_events = [] 4853 self.osc_events = [] 4854 self.raw_note_events = [] 4855 current_cycle = self._cycle_count 4856 self._cycle_count += 1 4857 4858 # lock(): re-deal the stream from its effective seed every 4859 # rebuild so a locked pattern realizes identically each cycle. 4860 # Checked here (engine-side) so it survives live reload. 4861 if self._builder_fn.__name__ in composition_ref._locked_names: 4862 locked_seed = composition_ref._stream_seed(self._builder_fn.__name__) 4863 if locked_seed is not None: 4864 self._rng = random.Random(locked_seed) 4865 4866 if self._muted: 4867 return 4868 4869 # The harmony view for this cycle, anchored at its start beat — 4870 # under variable harmonic rhythm the window, not the engine's 4871 # mutating singleton, is the source of truth. 4872 harmony_view: typing.Optional[HarmonyView] = None 4873 4874 if not composition_ref._harmony_horizon.is_empty: 4875 origin_beat = self._cycle_start_pulse / composition_ref._sequencer.pulses_per_beat 4876 harmony_view = HarmonyView(composition_ref._harmony_horizon, origin_beat) 4877 4878 builder = subsequence.pattern_builder.PatternBuilder( 4879 pattern = self, 4880 cycle = current_cycle, 4881 drum_note_map = self._drum_note_map, 4882 cc_name_map = self._cc_name_map, 4883 nrpn_name_map = self._nrpn_name_map, 4884 section = composition_ref._form_state.get_section_info() if composition_ref._form_state else None, 4885 bar = composition_ref._builder_bar, 4886 conductor = composition_ref.conductor, 4887 rng = self._rng, 4888 tweaks = self._tweaks, 4889 default_grid = self._default_grid, 4890 data = composition_ref.data, 4891 key = composition_ref.key, 4892 scale = composition_ref.scale, 4893 time_signature = composition_ref.time_signature, 4894 held_notes = composition_ref._sequencer._held_notes, 4895 harmony = harmony_view, 4896 section_motifs = composition_ref._section_motifs 4897 ) 4898 4899 try: 4900 4901 if self._wants_chord: 4902 4903 # The two-parameter convention: the injected chord is 4904 # the cycle-start snapshot from the window (falling 4905 # back to the engine before the clock has run). 4906 chord = harmony_view.chord if harmony_view is not None else ( 4907 composition_ref._harmonic_state.get_current_chord() 4908 if composition_ref._harmonic_state is not None else None 4909 ) 4910 4911 if chord is not None: 4912 injected = _InjectedChord( 4913 chord, 4914 self._voice_leading_state, 4915 next_chord = harmony_view.next_chord if harmony_view is not None else None, 4916 beats_remaining = harmony_view.until_change if harmony_view is not None else None, 4917 ) 4918 self._builder_fn(builder, injected) 4919 else: 4920 self._builder_fn(builder) 4921 4922 else: 4923 self._builder_fn(builder) 4924 4925 except Exception: 4926 logger.exception("Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", self._builder_fn.__name__, current_cycle) 4927 4928 # Auto-apply global tuning if set and not already applied by the builder. 4929 if ( 4930 composition_ref._tuning is not None 4931 and not builder._tuning_applied 4932 and not (composition_ref._tuning_exclude_drums and self._drum_note_map) 4933 ): 4934 import subsequence.tuning as _tuning_mod 4935 _tuning_mod.apply_tuning_to_pattern( 4936 self, 4937 composition_ref._tuning, 4938 bend_range=composition_ref._tuning_bend_range, 4939 channels=composition_ref._tuning_channels, 4940 reference_note=composition_ref._tuning_reference_note, 4941 ) 4942 4943 def on_reschedule (self) -> None: 4944 4945 """ 4946 Rebuild the pattern from the builder function before the next cycle. 4947 """ 4948 4949 self._rebuild() 4950 4951 return _DecoratorPattern(pending, rng)
The top-level controller for a musical piece.
The Composition object manages the global clock (Sequencer), the harmonic
progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns.
It serves as the main entry point for defining your music.
Typical workflow:
- Initialize
Compositionwith BPM and Key. - Define harmony and form (optional).
- Register patterns using the
@composition.patterndecorator. - Call
composition.play()to start the music.
1039 def __init__ ( 1040 self, 1041 output_device: typing.Optional[str] = None, 1042 bpm: float = 120, 1043 time_signature: typing.Tuple[int, int] = (4, 4), 1044 key: typing.Optional[str] = None, 1045 scale: typing.Optional[str] = None, 1046 seed: typing.Optional[int] = None, 1047 record: bool = False, 1048 record_filename: typing.Optional[str] = None, 1049 zero_indexed_channels: bool = False, 1050 latency_ms: float = 0.0 1051 ) -> None: 1052 1053 """ 1054 Initialize a new composition. 1055 1056 Parameters: 1057 output_device: The exact name of the MIDI output port to use, 1058 as reported by ``mido.get_output_names()``. Matching is 1059 strict — the string must equal an entry in that list 1060 verbatim. On Linux/ALSA, names include the client and 1061 port IDs (e.g. 1062 ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the 1063 trailing ``:client:port`` digits are assigned in 1064 connection order and can change between reboots or when 1065 a virtual port is recreated. To look up the current 1066 names:: 1067 1068 import mido 1069 for n in mido.get_output_names(): print(n) 1070 1071 If `None`, Subsequence auto-discovers — uses the only 1072 available device, or prompts to choose if several exist. 1073 bpm: Initial tempo in beats per minute (default 120). 1074 key: The root key of the piece (e.g., "C", "F#", "Bb"). 1075 Required if you plan to use `harmony()`. 1076 scale: The scale/mode of the piece (e.g. "minor", "dorian", 1077 or any registered scale name). Used to resolve scale 1078 degrees in motifs; defaults to major (ionian) when unset. 1079 seed: An optional integer for deterministic randomness. When set, 1080 every random decision (chord choices, drum probability, etc.) 1081 will be identical on every run. 1082 record: When True, record all MIDI events to a file. 1083 record_filename: Optional filename for the recording (defaults to timestamp). 1084 zero_indexed_channels: When False (default), MIDI channels use 1085 1-based numbering (1-16) matching instrument labelling. 1086 Channel 10 is drums, the way musicians and hardware panels 1087 show it. When True, channels use 0-based numbering (0-15) 1088 matching the raw MIDI protocol. 1089 latency_ms: Physical output latency of the primary device in 1090 milliseconds, for delay compensation (default 0.0, must be 1091 non-negative). Set this when the primary output sounds late 1092 (e.g. a software sampler) so Subsequence delays faster 1093 devices to line everything up. See ``midi_output()`` for 1094 additional devices. 1095 1096 Example: 1097 ```python 1098 comp = subsequence.Composition(bpm=128, key="Eb", seed=123) 1099 ``` 1100 """ 1101 1102 if latency_ms < 0: 1103 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 1104 1105 self.output_device = output_device 1106 self.bpm = bpm 1107 self.time_signature = time_signature 1108 self.key = key 1109 self.scale = scale 1110 self._seed: typing.Optional[int] = seed 1111 self._zero_indexed_channels: bool = zero_indexed_channels 1112 self._output_latency_ms: float = latency_ms 1113 1114 # Determinism plumbing: named-stream derivation state. Build-time 1115 # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so 1116 # adding one call never shifts another's stream; play-time pattern 1117 # streams are name-keyed in _build_pattern_from_pending. 1118 self._freeze_count: int = 0 1119 self._harmony_count: int = 0 1120 self._form_count: int = 0 1121 self._reroll_nonces: typing.Dict[str, int] = {} 1122 self._locked_names: typing.Set[str] = set() 1123 1124 self._sequencer = subsequence.sequencer.Sequencer( 1125 output_device_name = output_device, 1126 initial_bpm = bpm, 1127 time_signature = time_signature, 1128 record = record, 1129 record_filename = record_filename 1130 ) 1131 1132 self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None 1133 self._harmony_cycle_beats: typing.Optional[int] = None 1134 self._harmony_reschedule_lookahead: float = 1 1135 self._section_progressions: typing.Dict[str, Progression] = {} 1136 self._bound_progression: typing.Optional[Progression] = None 1137 self._pinned_chords: typing.Dict[int, typing.Any] = {} 1138 self._harmony_horizon = _HarmonyHorizon() 1139 self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {} 1140 self._pending_patterns: typing.List[_PendingPattern] = [] 1141 # Names of patterns declared by the most recent live-reload exec (added by 1142 # pattern()/layer() as they run); the deletion diff in _apply_source_async 1143 # tears down any running pattern absent from this set. 1144 self._declared_names: typing.Set[str] = set() 1145 self._pending_scheduled: typing.List[_PendingScheduled] = [] 1146 self._form_state: typing.Optional[subsequence.form_state.FormState] = None 1147 self._builder_bar: int = 0 1148 self._display: typing.Optional[subsequence.display.Display] = None 1149 self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None 1150 self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None 1151 self._is_live: bool = False 1152 self._running_patterns: typing.Dict[str, typing.Any] = {} 1153 self._input_device: typing.Optional[str] = None 1154 self._input_device_alias: typing.Optional[str] = None 1155 self._clock_follow: bool = False 1156 self._clock_output: bool = False 1157 self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] 1158 self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] 1159 # Held-note input config from note_input() (None = not declared). 1160 self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None 1161 # Additional output devices registered with midi_output() after construction. 1162 self._additional_outputs: typing.List[_AdditionalOutput] = [] 1163 # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) 1164 self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = [] 1165 # Maps alias/name → output device index (populated in _run after all devices are opened). 1166 self._output_device_names: typing.Dict[str, int] = {} 1167 # Maps alias/name → input device index (populated in _run after all input devices are opened). 1168 self._input_device_names: typing.Dict[str, int] = {} 1169 self.data: typing.Dict[str, typing.Any] = {} 1170 self._osc_server: typing.Optional[subsequence.osc.OscServer] = None 1171 self.conductor = subsequence.conductor.Conductor() 1172 self._web_ui_enabled: bool = False 1173 self._web_ui_http_host: str = "127.0.0.1" 1174 self._web_ui_ws_host: str = "127.0.0.1" 1175 self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None 1176 self._link_quantum: typing.Optional[float] = None 1177 1178 # Hotkey state — populated by hotkeys() and hotkey(). 1179 self._hotkeys_enabled: bool = False 1180 self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} 1181 self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] 1182 self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None 1183 1184 # Tuning state — populated by tuning(). 1185 self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning 1186 self._tuning_bend_range: float = 2.0 1187 self._tuning_channels: typing.Optional[typing.List[int]] = None 1188 self._tuning_reference_note: int = 60 1189 self._tuning_exclude_drums: bool = True
Initialize a new composition.
Arguments:
output_device: The exact name of the MIDI output port to use, as reported by
mido.get_output_names(). Matching is strict — the string must equal an entry in that list verbatim. On Linux/ALSA, names include the client and port IDs (e.g."Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"); the trailing:client:portdigits are assigned in connection order and can change between reboots or when a virtual port is recreated. To look up the current names::import mido for n in mido.get_output_names(): print(n)If
None, Subsequence auto-discovers — uses the only available device, or prompts to choose if several exist.- bpm: Initial tempo in beats per minute (default 120).
- key: The root key of the piece (e.g., "C", "F#", "Bb").
Required if you plan to use
harmony(). - scale: The scale/mode of the piece (e.g. "minor", "dorian", or any registered scale name). Used to resolve scale degrees in motifs; defaults to major (ionian) when unset.
- seed: An optional integer for deterministic randomness. When set, every random decision (chord choices, drum probability, etc.) will be identical on every run.
- record: When True, record all MIDI events to a file.
- record_filename: Optional filename for the recording (defaults to timestamp).
- zero_indexed_channels: When False (default), MIDI channels use 1-based numbering (1-16) matching instrument labelling. Channel 10 is drums, the way musicians and hardware panels show it. When True, channels use 0-based numbering (0-15) matching the raw MIDI protocol.
- latency_ms: Physical output latency of the primary device in
milliseconds, for delay compensation (default 0.0, must be
non-negative). Set this when the primary output sounds late
(e.g. a software sampler) so Subsequence delays faster
devices to line everything up. See
midi_output()for additional devices.
Example:
comp = subsequence.Composition(bpm=128, key="Eb", seed=123)
1385 @property 1386 def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: 1387 """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" 1388 return self._harmonic_state
The active HarmonicState, or None if harmony() has not been called.
1390 def current_chord (self) -> typing.Optional[typing.Any]: 1391 1392 """The chord sounding at the playhead, or ``None`` without harmony. 1393 1394 Reads the harmony window at the current pulse, so it stays accurate 1395 under variable harmonic rhythm and clock lookahead (the engine's 1396 ``current_chord`` flips *lookahead* beats early — this does not). 1397 Falls back to the engine's chord before playback starts. The chord 1398 may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span 1399 is spiced; it duck-types the ``Chord`` voicing protocol either way. 1400 """ 1401 1402 if not self._harmony_horizon.is_empty: 1403 beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat 1404 chord = self._harmony_horizon.chord_at(beat) 1405 if chord is not None: 1406 return chord 1407 1408 if self._harmonic_state is not None: 1409 return self._harmonic_state.get_current_chord() 1410 1411 return None
The chord sounding at the playhead, or None without harmony.
Reads the harmony window at the current pulse, so it stays accurate
under variable harmonic rhythm and clock lookahead (the engine's
current_chord flips lookahead beats early — this does not).
Falls back to the engine's chord before playback starts. The chord
may be a decorated wrapper (Am9, C/G) when the sounding span
is spiced; it duck-types the Chord voicing protocol either way.
1413 @property 1414 def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]: 1415 """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" 1416 return self._form_state
The active subsequence.form_state.FormState, or None if form() has not been called.
1418 @property 1419 def sequencer (self) -> subsequence.sequencer.Sequencer: 1420 """The underlying ``Sequencer`` instance.""" 1421 return self._sequencer
The underlying Sequencer instance.
1423 @property 1424 def running_patterns (self) -> typing.Dict[str, typing.Any]: 1425 """The currently active patterns, keyed by name.""" 1426 return self._running_patterns
The currently active patterns, keyed by name.
1428 @property 1429 def builder_bar (self) -> int: 1430 """Current bar index used by pattern builders.""" 1431 return self._builder_bar
Current bar index used by pattern builders.
1463 def harmony ( 1464 self, 1465 style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None, 1466 cycle_beats: int = 4, 1467 dominant_7th: bool = True, 1468 gravity: float = 1.0, 1469 nir_strength: float = 0.5, 1470 minor_turnaround_weight: float = 0.0, 1471 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1472 reschedule_lookahead: float = 1, 1473 progression: typing.Optional[typing.Any] = None, 1474 ) -> None: 1475 1476 """ 1477 Configure the harmonic logic and chord change intervals. 1478 1479 Two sources, combinable: a **bound progression** (``progression=`` — a 1480 :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, 1481 or chord names) walked span by span on the global clock; and/or a 1482 **graph style** stepping live chords. With only a progression bound, 1483 it loops on exhaustion; with a style configured too, exhaustion falls 1484 through to live stepping (the frozen-replay bridge). Calling with 1485 neither argument keeps today's default live engine 1486 (``style="functional_major"``). 1487 1488 Parameters: 1489 style: The harmonic style to use. Built-in: "functional_major" 1490 (alias "diatonic_major"), "turnaround", "aeolian_minor", 1491 "phrygian_minor", "lydian_major", "dorian_minor", 1492 "chromatic_mediant", "suspended", "mixolydian", "whole_tone", 1493 "diminished". See README for full descriptions. 1494 cycle_beats: How many beats each live chord lasts (default 4). 1495 Bound progressions carry their own harmonic rhythm in their 1496 spans, so this applies to live stepping only. 1497 dominant_7th: Whether to include V7 chords (default True). 1498 gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. 1499 nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement 1500 expectations. 1501 minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. 1502 root_diversity: Root-repetition damping (0.0 to 1.0). Each recent 1503 chord sharing a candidate's root reduces the weight to 40% at 1504 the default (0.4). Set to 1.0 to disable. 1505 reschedule_lookahead: How many beats in advance to calculate the 1506 next chord. 1507 progression: A progression to bind to the global clock. Key- 1508 relative content resolves now, against the composition key 1509 and scale (binding freezes one realisation). 1510 1511 Example: 1512 ```python 1513 # A moody minor progression that changes every 8 beats 1514 comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) 1515 1516 # Manual harmony driving everything — loops forever 1517 comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) 1518 ``` 1519 """ 1520 1521 if style is None and progression is None: 1522 style = "functional_major" 1523 1524 if style is not None: 1525 1526 if self.key is None: 1527 raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor") 1528 1529 preserved_history: typing.List[subsequence.chords.Chord] = [] 1530 preserved_current: typing.Optional[subsequence.chords.Chord] = None 1531 1532 if self._harmonic_state is not None: 1533 preserved_history = self._harmonic_state.history.copy() 1534 preserved_current = self._harmonic_state.current_chord 1535 1536 # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call 1537 # gets its own deterministic stream while history and current chord 1538 # are preserved above, and adding a re-call never shifts any other 1539 # consumer's stream. 1540 self._harmony_count += 1 1541 1542 self._harmonic_state = subsequence.harmonic_state.HarmonicState( 1543 key_name = self.key, 1544 graph_style = style, 1545 include_dominant_7th = dominant_7th, 1546 key_gravity_blend = gravity, 1547 nir_strength = nir_strength, 1548 minor_turnaround_weight = minor_turnaround_weight, 1549 root_diversity = root_diversity, 1550 rng = self._stream(f"harmony:{self._harmony_count}") 1551 ) 1552 1553 if preserved_history: 1554 self._harmonic_state.history = preserved_history 1555 if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current): 1556 self._harmonic_state.current_chord = preserved_current 1557 1558 if progression is not None: 1559 self._bound_progression = self._coerce_progression(progression, "harmony(progression=)") 1560 1561 self._harmony_cycle_beats = cycle_beats 1562 self._harmony_reschedule_lookahead = reschedule_lookahead 1563 1564 # A re-call invalidates whatever the horizon had planned. 1565 self._harmony_horizon.invalidate_future()
Configure the harmonic logic and chord change intervals.
Two sources, combinable: a bound progression (progression= — a
Progression value, an element list like [1, 6, 3, "bVII7"],
or chord names) walked span by span on the global clock; and/or a
graph style stepping live chords. With only a progression bound,
it loops on exhaustion; with a style configured too, exhaustion falls
through to live stepping (the frozen-replay bridge). Calling with
neither argument keeps today's default live engine
(style="functional_major").
Arguments:
- style: The harmonic style to use. Built-in: "functional_major" (alias "diatonic_major"), "turnaround", "aeolian_minor", "phrygian_minor", "lydian_major", "dorian_minor", "chromatic_mediant", "suspended", "mixolydian", "whole_tone", "diminished". See README for full descriptions.
- cycle_beats: How many beats each live chord lasts (default 4). Bound progressions carry their own harmonic rhythm in their spans, so this applies to live stepping only.
- dominant_7th: Whether to include V7 chords (default True).
- gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord.
- nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement expectations.
- minor_turnaround_weight: For "turnaround" style, influences major vs minor feel.
- root_diversity: Root-repetition damping (0.0 to 1.0). Each recent chord sharing a candidate's root reduces the weight to 40% at the default (0.4). Set to 1.0 to disable.
- reschedule_lookahead: How many beats in advance to calculate the next chord.
- progression: A progression to bind to the global clock. Key- relative content resolves now, against the composition key and scale (binding freezes one realisation).
Example:
# A moody minor progression that changes every 8 beats comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) # Manual harmony driving everything — loops forever comp.harmony(progression=subsequence.progression([1, 6, 3, 7]))
1567 def freeze ( 1568 self, 1569 bars: int, 1570 end: typing.Optional[typing.Any] = None, 1571 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1572 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1573 ) -> "Progression": 1574 1575 """Capture a chord progression from the live harmony engine. 1576 1577 Runs the harmony engine forward by *bars* chord changes, records each 1578 chord, and returns it as a :class:`Progression` that can be bound to a 1579 form section with :meth:`section_chords`. 1580 1581 The engine state **advances** — successive ``freeze()`` calls produce a 1582 continuing compositional journey so section progressions feel like parts 1583 of a whole rather than isolated islands. 1584 1585 The hybrid constraints compile into the walk: ``end=`` fixes the last 1586 bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` 1587 excludes chords throughout. Specs follow the progression-element 1588 grammar (ints where diatonic, roman/name strings where chromatic) and 1589 resolve against the composition key and scale. A backward 1590 feasibility pass guarantees satisfiability before any chord is drawn; 1591 the forward walk keeps the engine's real history-dependent weighting. 1592 Bar 1 is always the engine's current chord — the journey continues — 1593 so ``pins={1: ...}`` may only name it redundantly. 1594 1595 Parameters: 1596 bars: Number of chords to capture (one per harmony cycle). 1597 end: The chord at the final bar — ``end="V"`` is the cadential 1598 major dominant in minor. 1599 pins: ``{bar: chord}`` — 1-based fiat positions. 1600 avoid: Chords excluded from the walk. 1601 1602 Returns: 1603 A :class:`Progression` with the captured chords and trailing 1604 history for NIR continuity. 1605 1606 Raises: 1607 ValueError: If :meth:`harmony` has not been called first, or the 1608 constraints are contradictory or unsatisfiable. 1609 1610 Example:: 1611 1612 composition.harmony(style="functional_major", cycle_beats=4) 1613 verse = composition.freeze(8, end="V") # the verse sets up the chorus 1614 chorus = composition.freeze(4) # next 4 chords, continuing on 1615 composition.section_chords("verse", verse) 1616 composition.section_chords("chorus", chorus) 1617 """ 1618 1619 hs = self._require_harmonic_state() 1620 1621 if bars < 1: 1622 raise ValueError("bars must be at least 1") 1623 1624 scale = self.scale or "ionian" 1625 key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc 1626 1627 resolved_pins = { 1628 position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]") 1629 for position, spec in (pins or {}).items() 1630 } 1631 resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None 1632 resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])] 1633 1634 if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: 1635 raise ValueError( 1636 f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " 1637 f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " 1638 "pin a later bar, or use pin_chord() for playback fiat" 1639 ) 1640 1641 # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws 1642 # are independent of every other consumer, so frozen progressions are 1643 # reproducible WITHOUT play() and adding a call cannot shift a 1644 # neighbour's output. Engine state still advances normally — chord 1645 # continuity comes from current_chord/history, randomness from the 1646 # salted stream (swap-and-restore keeps hs.rng for play untouched). 1647 self._freeze_count += 1 1648 stream = self._stream(f"freeze:{self._freeze_count}") 1649 saved_rng = hs.rng 1650 1651 if stream is not None: 1652 hs.rng = stream 1653 1654 try: 1655 # The kernel with the engine's own hooks is draw-for-draw the old 1656 # step() loop when unconstrained — one walk path for both. 1657 def _commit (chosen: subsequence.chords.Chord) -> None: 1658 hs.current_chord = chosen 1659 1660 collected = subsequence.sequence_utils.constrained_walk( 1661 hs.graph, 1662 hs.current_chord, 1663 bars, 1664 rng = hs.rng, 1665 pins = resolved_pins, 1666 end = resolved_end, 1667 avoid = resolved_avoid, 1668 weight_modifier = hs._transition_weight, 1669 before_choice = hs._record_transition_source, 1670 after_choice = _commit, 1671 ) 1672 1673 # Advance past the last captured chord so the next freeze() call or 1674 # live playback does not duplicate it. 1675 hs.step() 1676 1677 finally: 1678 hs.rng = saved_rng 1679 1680 span_beats = float(self._harmony_cycle_beats or 4) 1681 1682 return Progression( 1683 spans = tuple( 1684 subsequence.progressions.ChordSpan(chord = chord, beats = span_beats) 1685 for chord in collected 1686 ), 1687 trailing_history = tuple(hs.history), 1688 )
Capture a chord progression from the live harmony engine.
Runs the harmony engine forward by bars chord changes, records each
chord, and returns it as a Progression that can be bound to a
form section with section_chords().
The engine state advances — successive freeze() calls produce a
continuing compositional journey so section progressions feel like parts
of a whole rather than isolated islands.
The hybrid constraints compile into the walk: end= fixes the last
bar ("end on V at bar 8"), pins= fix any 1-based bar, avoid=
excludes chords throughout. Specs follow the progression-element
grammar (ints where diatonic, roman/name strings where chromatic) and
resolve against the composition key and scale. A backward
feasibility pass guarantees satisfiability before any chord is drawn;
the forward walk keeps the engine's real history-dependent weighting.
Bar 1 is always the engine's current chord — the journey continues —
so pins={1: ...} may only name it redundantly.
Arguments:
- bars: Number of chords to capture (one per harmony cycle).
- end: The chord at the final bar —
end="V"is the cadential major dominant in minor. - pins:
{bar: chord}— 1-based fiat positions. - avoid: Chords excluded from the walk.
Returns:
A
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)
1690 def section_chords (self, section_name: str, progression: typing.Any) -> None: 1691 1692 """Bind a :class:`Progression` to a named form section. 1693 1694 Every time *section_name* plays, the harmonic clock walks the 1695 progression's spans instead of calling the live engine. Sections 1696 without a bound progression continue generating live chords. 1697 1698 Accepts a :class:`Progression` value (from :meth:`freeze`, the 1699 ``progression()`` factory, or hand-built) or anything the factory 1700 accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord 1701 names. Key-relative content resolves now, against the composition 1702 key and scale. 1703 1704 On exhaustion mid-section the progression loops when no graph style 1705 is configured (and always when it contains a 1706 :class:`~subsequence.progressions.PitchSet`); with a live engine, 1707 exhaustion falls through to live stepping until the section changes. 1708 1709 Parameters: 1710 section_name: Name of the section as defined in :meth:`form`. 1711 progression: The progression to bind. 1712 1713 Raises: 1714 ValueError: If a graph-based form has been configured and 1715 *section_name* is not one of its sections. List and generator 1716 forms yield names lazily, so they cannot be validated here. 1717 1718 Example:: 1719 1720 composition.section_chords("verse", verse_progression) 1721 composition.section_chords("chorus", [1, 6, 3, 7]) 1722 # "bridge" is not bound — it generates live chords 1723 """ 1724 1725 if ( 1726 self._form_state is not None 1727 and self._form_state._section_bars is not None 1728 and section_name not in self._form_state._section_bars 1729 ): 1730 known = ", ".join(sorted(self._form_state._section_bars)) 1731 raise ValueError( 1732 f"Section '{section_name}' not found in form. " 1733 f"Known sections: {known}" 1734 ) 1735 1736 self._section_progressions[section_name] = self._coerce_progression( 1737 progression, f"section_chords({section_name!r})" 1738 ) 1739 self._harmony_horizon.invalidate_future()
Bind a Progression to a named form section.
Every time section_name plays, the harmonic clock walks the progression's spans instead of calling the live engine. Sections without a bound progression continue generating live chords.
Accepts a Progression value (from freeze(), the
progression() factory, or hand-built) or anything the factory
accepts — an element list like [1, 6, 3, "bVII7"] or chord
names. Key-relative content resolves now, against the composition
key and scale.
On exhaustion mid-section the progression loops when no graph style
is configured (and always when it contains a
~subsequence.progressions.PitchSet); with a live engine,
exhaustion falls through to live stepping until the section changes.
Arguments:
- section_name: Name of the section as defined in
form(). - progression: The progression to bind.
Raises:
- ValueError: If a graph-based form has been configured and section_name is not one of its sections. List and generator forms yield names lazily, so they cannot be validated here.
Example::
composition.section_chords("verse", verse_progression)
composition.section_chords("chorus", [1, 6, 3, 7])
# "bridge" is not bound — it generates live chords
1741 def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None: 1742 1743 """Force the chord sounding at a bar — fiat over live generation. 1744 1745 Whatever the harmonic source (live walk, bound progression, section 1746 progression) produces for *bar*, the pinned chord overrides it. 1747 Pass ``None`` to remove a pin. 1748 1749 Parameters: 1750 bar: 1-based bar number (the musician count). 1751 chord: A chord name, int degree, roman string, ``Chord``, 1752 ``PitchSet``, or ``None`` to unpin. Key-relative specs 1753 resolve now, against the composition key and scale. 1754 1755 Example:: 1756 1757 composition.pin_chord(8, "E7") # the turnaround lands on E7 1758 composition.pin_chord(8, None) # let it walk again 1759 """ 1760 1761 if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 1762 raise ValueError(f"bars are 1-based ints, got {bar!r}") 1763 1764 if chord is None: 1765 self._pinned_chords.pop(bar, None) 1766 else: 1767 span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0])) 1768 1769 if not span.is_concrete: 1770 if self.key is None: 1771 raise ValueError("pin_chord with a key-relative spec needs key= on the Composition") 1772 span = span.resolve(subsequence.chords.key_name_to_pc(self.key), self.scale or "ionian") 1773 1774 self._pinned_chords[bar] = _span_chord(span) 1775 1776 self._harmony_horizon.invalidate_future()
Force the chord sounding at a bar — fiat over live generation.
Whatever the harmonic source (live walk, bound progression, section
progression) produces for bar, the pinned chord overrides it.
Pass None to remove a pin.
Arguments:
- bar: 1-based bar number (the musician count).
- chord: A chord name, int degree, roman string,
Chord,PitchSet, orNoneto unpin. Key-relative specs resolve now, against the composition key and scale.
Example::
composition.pin_chord(8, "E7") # the turnaround lands on E7
composition.pin_chord(8, None) # let it walk again
1778 def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None: 1779 1780 """Bind a Motif or Phrase to a named form section (per optional part). 1781 1782 Patterns read the binding back with ``p.section_motif(part)`` (or use 1783 the one-call :meth:`phrase_part`); a section with no binding for the 1784 part is silent for that part — bind material or don't, no fallback 1785 guessing. Re-binding is idempotent, so the call is safe in a live 1786 file: re-executing on save is the desired rebind. 1787 1788 Parameters: 1789 section_name: Name of the section as defined in :meth:`form`. 1790 value: A ``Motif`` or ``Phrase`` (anything exposing 1791 ``.length``/``.slice`` places). 1792 part: Optional part label, so one section can carry several 1793 bindings (``"lead"``, ``"bass"``, ...). 1794 1795 Raises: 1796 ValueError: If a graph-based form has been configured and 1797 *section_name* is not one of its sections. 1798 1799 Example:: 1800 1801 composition.section_motifs("verse", verse_line, part="lead") 1802 composition.section_motifs("chorus", chorus_line, part="lead") 1803 """ 1804 1805 if not hasattr(value, "length") or not hasattr(value, "slice"): 1806 raise TypeError( 1807 f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" 1808 ) 1809 1810 if ( 1811 self._form_state is not None 1812 and self._form_state._section_bars is not None 1813 and section_name not in self._form_state._section_bars 1814 ): 1815 known = ", ".join(sorted(self._form_state._section_bars)) 1816 raise ValueError( 1817 f"Section '{section_name}' not found in form. " 1818 f"Known sections: {known}" 1819 ) 1820 1821 self._section_motifs[(section_name, part)] = value
Bind a Motif or Phrase to a named form section (per optional part).
Patterns read the binding back with p.section_motif(part) (or use
the one-call phrase_part()); a section with no binding for the
part is silent for that part — bind material or don't, no fallback
guessing. Re-binding is idempotent, so the call is safe in a live
file: re-executing on save is the desired rebind.
Arguments:
- section_name: Name of the section as defined in
form(). - value: A
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")
1823 def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: 1824 1825 """ 1826 Register a callback for a sequencer event (e.g., "bar", "start", "stop"). 1827 """ 1828 1829 self._sequencer.on_event(event_name, callback)
Register a callback for a sequencer event (e.g., "bar", "start", "stop").
1836 def hotkeys (self, enabled: bool = True) -> None: 1837 1838 """Enable or disable the global hotkey listener. 1839 1840 Must be called **before** :meth:`play` to take effect. When enabled, a 1841 background thread reads single keystrokes from stdin without requiring 1842 Enter. The ``?`` key is always reserved and lists all active bindings. 1843 1844 Hotkeys have zero impact on playback when disabled — the listener 1845 thread is never started. 1846 1847 Args: 1848 enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. 1849 1850 Example:: 1851 1852 composition.hotkeys() 1853 composition.hotkey("a", lambda: composition.form_jump("chorus")) 1854 composition.play() 1855 """ 1856 1857 self._hotkeys_enabled = enabled
Enable or disable the global hotkey listener.
Must be called before play() to take effect. When enabled, a
background thread reads single keystrokes from stdin without requiring
Enter. The ? key is always reserved and lists all active bindings.
Hotkeys have zero impact on playback when disabled — the listener thread is never started.
Arguments:
- enabled:
True(default) to enable hotkeys;Falseto disable.
Example::
composition.hotkeys()
composition.hotkey("a", lambda: composition.form_jump("chorus"))
composition.play()
1860 def hotkey ( 1861 self, 1862 key: str, 1863 action: typing.Callable[[], None], 1864 quantize: int = 0, 1865 label: typing.Optional[str] = None, 1866 ) -> None: 1867 1868 """Register a single-key shortcut that fires during playback. 1869 1870 The listener must be enabled first with :meth:`hotkeys`. 1871 1872 Most actions — form jumps, ``composition.data`` writes, and 1873 :meth:`tweak` calls — should use ``quantize=0`` (the default). Their 1874 musical effect is naturally delayed to the next pattern rebuild cycle, 1875 which provides automatic musical quantization without extra configuration. 1876 1877 Use ``quantize=N`` for actions where you want an explicit bar-boundary 1878 guarantee, such as :meth:`mute` / :meth:`unmute`. 1879 1880 The ``?`` key is reserved and cannot be overridden. 1881 1882 Args: 1883 key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). 1884 action: Zero-argument callable to execute. 1885 quantize: ``0`` = execute immediately (default). ``N`` = execute 1886 on the next global bar number divisible by *N*. 1887 label: Display name for the ``?`` help listing. Auto-derived from 1888 the function name or lambda body if omitted. 1889 1890 Raises: 1891 ValueError: If ``key`` is the reserved ``?`` character, or if 1892 ``key`` is not exactly one character. 1893 1894 Example:: 1895 1896 composition.hotkeys() 1897 1898 # Immediate — musical effect happens at next pattern rebuild 1899 composition.hotkey("a", lambda: composition.form_jump("chorus")) 1900 composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) 1901 1902 # Explicit 4-bar phrase boundary 1903 composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) 1904 1905 # Named function — label is derived automatically 1906 def drop_to_breakdown (): 1907 composition.form_jump("breakdown") 1908 composition.mute("lead") 1909 1910 composition.hotkey("d", drop_to_breakdown) 1911 1912 composition.play() 1913 """ 1914 1915 if len(key) != 1: 1916 raise ValueError(f"hotkey key must be a single character, got {key!r}") 1917 1918 if key == _HOTKEY_RESERVED: 1919 raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.") 1920 1921 derived = label if label is not None else _derive_label(action) 1922 1923 self._hotkey_bindings[key] = HotkeyBinding( 1924 key = key, 1925 action = action, 1926 quantize = quantize, 1927 label = derived, 1928 )
Register a single-key shortcut that fires during playback.
The listener must be enabled first with hotkeys().
Most actions — form jumps, composition.data writes, and
tweak() calls — should use quantize=0 (the default). Their
musical effect is naturally delayed to the next pattern rebuild cycle,
which provides automatic musical quantization without extra configuration.
Use quantize=N for actions where you want an explicit bar-boundary
guarantee, such as mute() / unmute().
The ? key is reserved and cannot be overridden.
Arguments:
- key: A single character trigger (e.g.
"a","1"," "). - action: Zero-argument callable to execute.
- quantize:
0= execute immediately (default).N= execute on the next global bar number divisible by N. - label: Display name for the
?help listing. Auto-derived from the function name or lambda body if omitted.
Raises:
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()
1931 def form_jump (self, section_name: str) -> None: 1932 1933 """Jump the form to a named section immediately. 1934 1935 Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the 1936 composition uses graph-mode form (a dict passed to :meth:`form`). 1937 1938 The musical effect is heard at the *next pattern rebuild cycle* — already- 1939 queued MIDI notes are unaffected. This natural delay means ``form_jump`` 1940 is effective without needing explicit quantization. 1941 1942 Args: 1943 section_name: The section to jump to. 1944 1945 Raises: 1946 ValueError: If no form is configured, or the form is not in graph 1947 mode, or *section_name* is unknown. 1948 1949 Example:: 1950 1951 composition.hotkey("c", lambda: composition.form_jump("chorus")) 1952 """ 1953 1954 if self._form_state is None: 1955 raise ValueError("form_jump() requires a form to be configured via composition.form().") 1956 1957 self._form_state.jump_to(section_name) 1958 1959 # The harmony horizon planned against the old section — revoke it. 1960 self._harmony_horizon.invalidate_future()
Jump the form to a named section immediately.
Delegates to subsequence.form_state.FormState.jump_to(). Only works when the
composition uses graph-mode form (a dict passed to form()).
The musical effect is heard at the next pattern rebuild cycle — already-
queued MIDI notes are unaffected. This natural delay means form_jump
is effective without needing explicit quantization.
Arguments:
- section_name: The section to jump to.
Raises:
- ValueError: If no form is configured, or the form is not in graph mode, or section_name is unknown.
Example::
composition.hotkey("c", lambda: composition.form_jump("chorus"))
1963 def form_next (self, section_name: str) -> None: 1964 1965 """Queue the next section — takes effect when the current section ends. 1966 1967 Unlike :meth:`form_jump`, this does not interrupt the current section. 1968 The queued section replaces the automatically pre-decided next section 1969 and takes effect at the natural section boundary. The performer can 1970 change their mind by calling ``form_next`` again before the boundary. 1971 1972 Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the 1973 composition uses graph-mode form (a dict passed to :meth:`form`). 1974 1975 Args: 1976 section_name: The section to queue. 1977 1978 Raises: 1979 ValueError: If no form is configured, or the form is not in graph 1980 mode, or *section_name* is unknown. 1981 1982 Example:: 1983 1984 composition.hotkey("c", lambda: composition.form_next("chorus")) 1985 """ 1986 1987 if self._form_state is None: 1988 raise ValueError("form_next() requires a form to be configured via composition.form().") 1989 1990 self._form_state.queue_next(section_name) 1991 1992 # The harmony horizon planned against the old continuation — revoke it. 1993 self._harmony_horizon.invalidate_future()
Queue the next section — takes effect when the current section ends.
Unlike form_jump(), this does not interrupt the current section.
The queued section replaces the automatically pre-decided next section
and takes effect at the natural section boundary. The performer can
change their mind by calling form_next again before the boundary.
Delegates to subsequence.form_state.FormState.queue_next(). Only works when the
composition uses graph-mode form (a dict passed to form()).
Arguments:
- section_name: The section to queue.
Raises:
- ValueError: If no form is configured, or the form is not in graph mode, or section_name is unknown.
Example::
composition.hotkey("c", lambda: composition.form_next("chorus"))
2077 @property 2078 def seed (self) -> typing.Optional[int]: 2079 2080 """ 2081 The composition's random seed, or None when unseeded. 2082 2083 When set, every random decision derives deterministically from this 2084 value through named streams (see ``seed_for()``), so the same script 2085 produces the same music on every run. Assign to set it:: 2086 2087 comp.seed = 42 2088 2089 (Formerly the method ``comp.seed(42)`` — the call form is a hard 2090 break per the pre-1.0 rename policy.) 2091 """ 2092 2093 return self._seed
The composition's random seed, or None when unseeded.
When set, every random decision derives deterministically from this
value through named streams (see seed_for()), so the same script
produces the same music on every run. Assign to set it::
comp.seed = 42
(Formerly the method comp.seed(42) — the call form is a hard
break per the pre-1.0 rename policy.)
2127 def seed_for (self, name: str) -> typing.Optional[int]: 2128 2129 """ 2130 Surface the effective derived seed for a named stream. 2131 2132 Works for pattern names and equally for any name you invent for a 2133 standalone value generator (``seed=composition.seed_for("hook")``), 2134 so its randomness keys off the composition seed without sharing any 2135 other consumer's stream. Reflects ``reroll()`` nonces. Returns None 2136 when the composition is unseeded. 2137 2138 Example: 2139 ```python 2140 hook_seed = composition.seed_for("hook") 2141 ``` 2142 """ 2143 2144 return self._stream_seed(name)
Surface the effective derived seed for a named stream.
Works for pattern names and equally for any name you invent for a
standalone value generator (seed=composition.seed_for("hook")),
so its randomness keys off the composition seed without sharing any
other consumer's stream. Reflects reroll() nonces. Returns None
when the composition is unseeded.
Example:
hook_seed = composition.seed_for("hook")
2146 def reroll (self, name: str) -> None: 2147 2148 """ 2149 Deal a named stream a fresh deterministic seed — try a new variation. 2150 2151 Bumps the per-name nonce and prints the new effective seed. The 2152 nonce lives only in this process, so the printed seed is what lets a 2153 variation you like survive a restart: note it down, or ``lock()`` the 2154 name to pin it for the session. Refuses on locked names. 2155 2156 Parameters: 2157 name: The stream name — usually a pattern name. 2158 2159 Example: 2160 ```python 2161 comp.reroll("lead") # prints: reroll('lead') -> effective seed ... 2162 ``` 2163 """ 2164 2165 if name in self._locked_names: 2166 print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first") 2167 return 2168 2169 self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 2170 effective = self._stream_seed(name) 2171 2172 if effective is None: 2173 print(f"reroll('{name}'): composition has no seed - randomness is unseeded") 2174 return 2175 2176 running = self._running_patterns.get(name) 2177 2178 if running is not None and hasattr(running, "_rng"): 2179 running._rng = random.Random(effective) 2180 2181 print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})")
Deal a named stream a fresh deterministic seed — try a new variation.
Bumps the per-name nonce and prints the new effective seed. The
nonce lives only in this process, so the printed seed is what lets a
variation you like survive a restart: note it down, or lock() the
name to pin it for the session. Refuses on locked names.
Arguments:
- name: The stream name — usually a pattern name.
Example:
comp.reroll("lead") # prints: reroll('lead') -> effective seed ...
2183 def lock (self, name: str) -> None: 2184 2185 """ 2186 Pin a named stream: keep its current effective seed and realization. 2187 2188 Engine-side state, so it survives live reload (it is never a builder 2189 swap): a locked pattern re-deals its stream from the same effective 2190 seed on every rebuild, so every cycle realizes identically, and 2191 ``reroll()`` refuses with a message until ``unlock()``. 2192 2193 Parameters: 2194 name: The stream name — usually a pattern name. 2195 """ 2196 2197 self._locked_names.add(name)
Pin a named stream: keep its current effective seed and realization.
Engine-side state, so it survives live reload (it is never a builder
swap): a locked pattern re-deals its stream from the same effective
seed on every rebuild, so every cycle realizes identically, and
reroll() refuses with a message until unlock().
Arguments:
- name: The stream name — usually a pattern name.
2205 def tuning ( 2206 self, 2207 source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, 2208 *, 2209 cents: typing.Optional[typing.List[float]] = None, 2210 ratios: typing.Optional[typing.List[float]] = None, 2211 equal: typing.Optional[int] = None, 2212 bend_range: float = 2.0, 2213 channels: typing.Optional[typing.List[int]] = None, 2214 reference_note: int = 60, 2215 exclude_drums: bool = True, 2216 ) -> None: 2217 2218 """Set a global microtonal tuning for the composition. 2219 2220 The tuning is applied automatically after each pattern rebuild (before 2221 the pattern is scheduled). Drum patterns (those registered with a 2222 ``drum_note_map``) are excluded by default. 2223 2224 Supply exactly one of the source parameters: 2225 2226 - ``source``: path to a Scala ``.scl`` file. 2227 - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). 2228 - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). 2229 - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). 2230 2231 For polyphonic parts, supply a ``channels`` pool. Notes are spread 2232 across those MIDI channels so each can carry an independent pitch bend. 2233 The synth must be configured to match ``bend_range`` (its pitch-bend range 2234 setting in semitones). 2235 2236 Parameters: 2237 source: Path to a ``.scl`` file. 2238 cents: Cent offsets for scale degrees 1..N. 2239 ratios: Frequency ratios for scale degrees 1..N. 2240 equal: Number of equal divisions of the period. 2241 bend_range: Synth pitch-bend range in semitones (default ±2). 2242 channels: Channel pool for polyphonic rotation. 2243 reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). 2244 exclude_drums: When True (default), skip patterns that have a 2245 ``drum_note_map`` (they use fixed GM pitches, not tuned ones). 2246 2247 Example: 2248 ```python 2249 # Quarter-comma meantone from a Scala file 2250 comp.tuning("meanquar.scl") 2251 2252 # Just intonation from ratios 2253 comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) 2254 2255 # 19-TET, monophonic 2256 comp.tuning(equal=19, bend_range=2.0) 2257 2258 # 31-TET with channel rotation for polyphony (channels 1-6) 2259 comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) 2260 ``` 2261 """ 2262 import subsequence.tuning as _tuning_mod 2263 2264 given = sum(x is not None for x in [source, cents, ratios, equal]) 2265 if given == 0: 2266 raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal") 2267 if given > 1: 2268 raise ValueError("composition.tuning() accepts only one source parameter") 2269 2270 if source is not None: 2271 t = _tuning_mod.Tuning.from_scl(source) 2272 elif cents is not None: 2273 t = _tuning_mod.Tuning.from_cents(cents) 2274 elif ratios is not None: 2275 t = _tuning_mod.Tuning.from_ratios(ratios) 2276 else: 2277 t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] 2278 2279 self._tuning = t 2280 self._tuning_bend_range = bend_range 2281 self._tuning_channels = channels 2282 self._tuning_reference_note = reference_note 2283 self._tuning_exclude_drums = exclude_drums
Set a global microtonal tuning for the composition.
The tuning is applied automatically after each pattern rebuild (before
the pattern is scheduled). Drum patterns (those registered with a
drum_note_map) are excluded by default.
Supply exactly one of the source parameters:
source: path to a Scala.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])
2285 def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None: 2286 2287 """ 2288 Enable or disable the live terminal dashboard. 2289 2290 When enabled, Subsequence uses a safe logging handler that allows a 2291 persistent status line (BPM, Key, Bar, Section, Chord) to stay at 2292 the bottom of the terminal while logs scroll above it. 2293 2294 Parameters: 2295 enabled: Whether to show the display (default True). 2296 grid: When True, render an ASCII grid visualisation of all 2297 running patterns above the status line. The grid updates 2298 once per bar, showing which steps have notes and at what 2299 velocity. 2300 grid_scale: Horizontal zoom factor for the grid (default 2301 ``1.0``). Higher values add visual columns between 2302 grid steps, revealing micro-timing from swing and groove. 2303 Snapped to the nearest integer internally for uniform 2304 marker spacing. 2305 """ 2306 2307 if enabled: 2308 self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale) 2309 else: 2310 self._display = None
Enable or disable the live terminal dashboard.
When enabled, Subsequence uses a safe logging handler that allows a persistent status line (BPM, Key, Bar, Section, Chord) to stay at the bottom of the terminal while logs scroll above it.
Arguments:
- enabled: Whether to show the display (default True).
- grid: When True, render an ASCII grid visualisation of all running patterns above the status line. The grid updates once per bar, showing which steps have notes and at what velocity.
- grid_scale: Horizontal zoom factor for the grid (default
1.0). Higher values add visual columns between grid steps, revealing micro-timing from swing and groove. Snapped to the nearest integer internally for uniform marker spacing.
2312 def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: 2313 2314 """ 2315 Enable the realtime Web UI Dashboard. 2316 2317 When enabled, Subsequence instantiates a WebSocket server that broadcasts 2318 the current state, signals, and active patterns (with high-res timing and 2319 note data) to any connected browser clients. 2320 2321 Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` 2322 (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but 2323 broadcasts full composition state, so only do so on a trusted network. 2324 """ 2325 2326 self._web_ui_enabled = True 2327 self._web_ui_http_host = http_host 2328 self._web_ui_ws_host = ws_host
Enable the realtime Web UI Dashboard.
When enabled, Subsequence instantiates a WebSocket server that broadcasts the current state, signals, and active patterns (with high-res timing and note data) to any connected browser clients.
Both servers bind to localhost by default. Pass http_host / ws_host
(e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but
broadcasts full composition state, so only do so on a trusted network.
2330 def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None: 2331 2332 """ 2333 Configure a MIDI input device for external sync and MIDI messages. 2334 2335 May be called multiple times to register additional input devices. 2336 The first call sets the primary input (device 0). Subsequent calls 2337 add additional input devices (device 1, 2, …). Only one device may 2338 have ``clock_follow=True``. 2339 2340 Parameters: 2341 device: The name of the MIDI input port. 2342 clock_follow: If True, Subsequence will slave its clock to incoming 2343 MIDI Ticks. It will also follow MIDI Start/Stop/Continue 2344 commands. Only one device can have this enabled at a time. 2345 name: Optional alias for use with ``cc_map(input_device=…)`` and 2346 ``cc_forward(input_device=…)``. When omitted, the raw device 2347 name is used. 2348 2349 Example: 2350 ```python 2351 # Single controller (unchanged usage) 2352 comp.midi_input("Scarlett 2i4", clock_follow=True) 2353 2354 # Multiple controllers 2355 comp.midi_input("Arturia KeyStep", name="keys") 2356 comp.midi_input("Faderfox EC4", name="faders") 2357 ``` 2358 """ 2359 2360 if clock_follow: 2361 if self.is_clock_following: 2362 raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)") 2363 2364 if self._input_device is None: 2365 # First call: set primary input device (device 0) 2366 self._input_device = device 2367 self._input_device_alias = name 2368 self._clock_follow = clock_follow 2369 else: 2370 # Subsequent calls: register additional input devices 2371 self._additional_inputs.append((device, name, clock_follow))
Configure a MIDI input device for external sync and MIDI messages.
May be called multiple times to register additional input devices.
The first call sets the primary input (device 0). Subsequent calls
add additional input devices (device 1, 2, …). Only one device may
have clock_follow=True.
Arguments:
- device: The name of the MIDI input port.
- clock_follow: If True, Subsequence will slave its clock to incoming MIDI Ticks. It will also follow MIDI Start/Stop/Continue commands. Only one device can have this enabled at a time.
- name: Optional alias for use with
cc_map(input_device=…)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")
2373 def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int: 2374 2375 """ 2376 Register an additional MIDI output device. 2377 2378 The first output device is always the one passed to 2379 ``Composition(output_device=…)`` — that is device 0. 2380 Each call to ``midi_output()`` adds the next device (1, 2, …). 2381 2382 Parameters: 2383 device: The exact name of the MIDI output port, as reported 2384 by ``mido.get_output_names()``. Matching is strict — 2385 partial names and substrings are rejected. See 2386 ``Composition.__init__`` for the lookup snippet and a 2387 note on ALSA name stability on Linux. 2388 name: Optional alias for use with ``pattern(device=…)``, 2389 ``cc_forward(output_device=…)``, etc. When omitted, the raw 2390 device name is used. 2391 latency_ms: Physical output latency of this device in 2392 milliseconds, for delay compensation (default 0.0, must be 2393 non-negative). Set this when the device sounds late (e.g. a 2394 software sampler) so Subsequence delays faster devices to 2395 line everything up. 2396 2397 Returns: 2398 The integer device index assigned (1, 2, 3, …). 2399 2400 Example: 2401 ```python 2402 comp = subsequence.Composition(bpm=120, output_device="MOTU Express") 2403 2404 # Returns 1 — use as device=1 or device="integra" 2405 comp.midi_output("Roland Integra", name="integra") 2406 2407 # A software sampler that sounds 20ms late 2408 comp.midi_output("Subsample", name="sampler", latency_ms=20) 2409 2410 @comp.pattern(channel=1, beats=4, device="integra") 2411 def strings (p): 2412 p.note(60, beat=0) 2413 ``` 2414 """ 2415 2416 if latency_ms < 0: 2417 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 2418 2419 idx = 1 + len(self._additional_outputs) # device 0 is always the primary 2420 self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms)) 2421 return idx
Register an additional MIDI output device.
The first output device is always the one passed to
Composition(output_device=…) — that is device 0.
Each call to midi_output() adds the next device (1, 2, …).
Arguments:
- device: The exact name of the MIDI output port, as reported
by
mido.get_output_names(). Matching is strict — partial names and substrings are rejected. SeeComposition.__init__for the lookup snippet and a note on ALSA name stability on Linux. - name: Optional alias for use with
pattern(device=…),cc_forward(output_device=…), etc. When omitted, the raw device name is used. - latency_ms: Physical output latency of this device in milliseconds, for delay compensation (default 0.0, must be non-negative). Set this when the device sounds late (e.g. a software sampler) so Subsequence delays faster devices to line everything up.
Returns:
The integer device index assigned (1, 2, 3, …).
Example:
comp = subsequence.Composition(bpm=120, output_device="MOTU Express") # Returns 1 — use as device=1 or device="integra" comp.midi_output("Roland Integra", name="integra") # A software sampler that sounds 20ms late comp.midi_output("Subsample", name="sampler", latency_ms=20) @comp.pattern(channel=1, beats=4, device="integra") def strings (p): p.note(60, beat=0)
2444 def clock_output (self, enabled: bool = True) -> None: 2445 2446 """ 2447 Send MIDI timing clock to connected hardware. 2448 2449 When enabled, Subsequence acts as a MIDI clock master and sends 2450 standard clock messages on the output port: a Start message (0xFA) 2451 when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), 2452 and a Stop message (0xFC) when playback ends. 2453 2454 This allows hardware synthesizers, drum machines, and effect units to 2455 slave their tempo to Subsequence automatically. 2456 2457 **Note:** Clock output is automatically disabled when ``midi_input()`` 2458 is called with ``clock_follow=True``, to prevent a clock feedback loop. 2459 2460 Parameters: 2461 enabled: Whether to send MIDI clock (default True). 2462 2463 Example: 2464 ```python 2465 comp = subsequence.Composition(bpm=120, output_device="...") 2466 comp.clock_output() # hardware will follow Subsequence tempo 2467 ``` 2468 """ 2469 2470 self._clock_output = enabled
Send MIDI timing clock to connected hardware.
When enabled, Subsequence acts as a MIDI clock master and sends standard clock messages on the output port: a Start message (0xFA) when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), and a Stop message (0xFC) when playback ends.
This allows hardware synthesizers, drum machines, and effect units to slave their tempo to Subsequence automatically.
Note: Clock output is automatically disabled when midi_input()
is called with clock_follow=True, to prevent a clock feedback loop.
Arguments:
- enabled: Whether to send MIDI clock (default True).
Example:
comp = subsequence.Composition(bpm=120, output_device="...") comp.clock_output() # hardware will follow Subsequence tempo
2473 def link (self, quantum: float = 4.0) -> "Composition": 2474 2475 """ 2476 Enable Ableton Link tempo and phase synchronisation. 2477 2478 When enabled, Subsequence joins the local Link session and slaves its 2479 clock to the shared network tempo and beat phase. All other Link-enabled 2480 apps on the same LAN — Ableton Live, iOS synths, other Subsequence 2481 instances — will automatically stay in time. 2482 2483 Playback starts on the next bar boundary aligned to the Link quantum, 2484 so downbeats stay in sync across all participants. 2485 2486 Requires the ``link`` optional extra:: 2487 2488 pip install subsequence[link] 2489 2490 Parameters: 2491 quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. 2492 Change this if your composition uses a different meter. 2493 2494 Example:: 2495 2496 comp = subsequence.Composition(bpm=120, key="C") 2497 comp.link() # join the Link session 2498 comp.play() 2499 2500 # On another machine / instance: 2501 comp2 = subsequence.Composition(bpm=120) 2502 comp2.link() # tempo and phase will lock to comp 2503 comp2.play() 2504 2505 Note: 2506 ``set_bpm()`` proposes the new tempo to the Link network when Link 2507 is active. The network-authoritative tempo is applied on the next 2508 pulse, so there may be a brief lag before the change is visible. 2509 """ 2510 2511 # Eagerly check that aalink is installed — fail early with a clear message. 2512 subsequence.link_clock._require_aalink() 2513 2514 self._link_quantum = quantum 2515 return self
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.
2518 def cc_map ( 2519 self, 2520 cc: int, 2521 data_key: str, 2522 channel: typing.Optional[int] = None, 2523 min_val: float = 0.0, 2524 max_val: float = 1.0, 2525 input_device: subsequence.midi_utils.DeviceId = None, 2526 ) -> None: 2527 2528 """ 2529 Map an incoming MIDI CC to a ``composition.data`` key. 2530 2531 When the composition receives a CC message on the configured MIDI 2532 input port, the value is scaled from the CC range (0–127) to 2533 *[min_val, max_val]* and stored in ``composition.data[data_key]``. 2534 2535 This lets hardware knobs, faders, and expression pedals control live 2536 parameters without writing any callback code. 2537 2538 **Requires** ``midi_input()`` to be called first to open an input port. 2539 2540 Parameters: 2541 cc: MIDI Control Change number (0–127). 2542 data_key: The ``composition.data`` key to write. 2543 channel: If given, only respond to CC messages on this channel. 2544 Uses the same numbering convention as ``pattern()`` (1-16 2545 by default, or 0-15 with ``zero_indexed_channels=True``). 2546 ``None`` matches any channel (default). 2547 min_val: Scaled minimum — written when CC value is 0 (default 0.0). 2548 max_val: Scaled maximum — written when CC value is 127 (default 1.0). 2549 input_device: Only respond to CC messages from this input device 2550 (index or name). ``None`` responds to any input device (default). 2551 2552 Example: 2553 ```python 2554 comp.midi_input("Arturia KeyStep") 2555 comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 2556 comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader 2557 2558 # Multi-device: only listen to CC 74 from the "faders" controller 2559 comp.cc_map(74, "filter", input_device="faders") 2560 ``` 2561 """ 2562 2563 resolved_channel = self._resolve_channel(channel) if channel is not None else None 2564 2565 self._cc_mappings.append({ 2566 'cc': cc, 2567 'data_key': data_key, 2568 'channel': resolved_channel, 2569 'min_val': min_val, 2570 'max_val': max_val, 2571 'input_device': input_device, # resolved to int index in _run() 2572 })
Map an incoming MIDI CC to a composition.data key.
When the composition receives a CC message on the configured MIDI
input port, the value is scaled from the CC range (0–127) to
[min_val, max_val] and stored in composition.data[data_key].
This lets hardware knobs, faders, and expression pedals control live parameters without writing any callback code.
Requires midi_input() to be called first to open an input port.
Arguments:
- cc: MIDI Control Change number (0–127).
- data_key: The
composition.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")
2575 def note_input ( 2576 self, 2577 channel: typing.Optional[int] = None, 2578 release_ms: float = 30.0, 2579 latch: bool = False, 2580 input_device: subsequence.midi_utils.DeviceId = None, 2581 ) -> None: 2582 2583 """Track notes held on a MIDI keyboard for live arpeggiation. 2584 2585 Incoming note-on/note-off messages build a live "currently held" set 2586 that any pattern reads via ``p.held_notes()`` — typically fed straight 2587 to ``p.arpeggio()``. The composition still authors the rhythm and 2588 motion; the player's hands supply the pitch set. This is a live 2589 *performance* layer over the deterministic, seeded composition: when 2590 rendering headlessly there is no input, so ``p.held_notes()`` is empty 2591 and seeded output is unchanged. 2592 2593 **Requires** ``midi_input()`` to be called first to open an input port. 2594 2595 Parameters: 2596 channel: If given, only track notes on this channel. Uses the same 2597 numbering convention as ``pattern()`` (1-16 by default, or 0-15 2598 with ``zero_indexed_channels=True``). ``None`` tracks any 2599 channel (default). 2600 release_ms: How long (milliseconds) a released note keeps counting 2601 as held. This smooths the momentary all-keys-up gap during a 2602 hand-position change so the arp does not drop to silence. 2603 Default 30.0; set 0.0 to release instantly. Ignored when 2604 ``latch`` is True. 2605 latch: When True, the held set persists after you lift your hands 2606 until you play a new chord (the first key after every key is up 2607 replaces it) — like a hardware arp's latch. 2608 input_device: Only track notes from this input device (index or 2609 name). ``None`` tracks any input device (default). 2610 2611 Example: 2612 ```python 2613 comp.midi_input("Arturia KeyStep") 2614 comp.note_input(channel=1, release_ms=30) 2615 2616 @comp.pattern(channel=6, beats=4) 2617 def arp (p): 2618 p.arpeggio(p.held_notes(), direction="up") # rests when silent 2619 ``` 2620 """ 2621 2622 if self._note_input is not None: 2623 raise RuntimeError("only one note_input source is supported — named multi-source is not yet available") 2624 2625 resolved_channel = self._resolve_channel(channel) if channel is not None else None 2626 2627 self._note_input = { 2628 'channel': resolved_channel, 2629 'release_ms': release_ms, 2630 'latch': latch, 2631 'input_device': input_device, # resolved to int index in _run() 2632 }
Track notes held on a MIDI keyboard for live arpeggiation.
Incoming note-on/note-off messages build a live "currently held" set
that any pattern reads via p.held_notes() — typically fed straight
to p.arpeggio(). The composition still authors the rhythm and
motion; the player's hands supply the pitch set. This is a live
performance layer over the deterministic, seeded composition: when
rendering headlessly there is no input, so p.held_notes() is empty
and seeded output is unchanged.
Requires midi_input() to be called first to open an input port.
Arguments:
- channel: If given, only track notes on this channel. Uses the same
numbering convention as
pattern()(1-16 by default, or 0-15 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
2696 def cc_forward ( 2697 self, 2698 cc: int, 2699 output: typing.Union[str, typing.Callable], 2700 *, 2701 channel: typing.Optional[int] = None, 2702 output_channel: typing.Optional[int] = None, 2703 mode: str = "instant", 2704 input_device: subsequence.midi_utils.DeviceId = None, 2705 output_device: subsequence.midi_utils.DeviceId = None, 2706 ) -> None: 2707 2708 """ 2709 Forward an incoming MIDI CC to the MIDI output in real-time. 2710 2711 Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` 2712 for use at pattern rebuild time, ``cc_forward()`` routes the signal 2713 directly to the MIDI output — bypassing the pattern cycle entirely. 2714 2715 Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC 2716 number; they operate independently. 2717 2718 Parameters: 2719 cc: Incoming CC number to listen for (0–127). 2720 output: What to send. Either a **preset string**: 2721 2722 - ``"cc"`` — identity forward, same CC number and value. 2723 - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). 2724 - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. 2725 2726 Or a **callable** with signature 2727 ``(value: int, channel: int) -> Optional[mido.Message]``. 2728 Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. 2729 ``channel`` is 0-indexed (the incoming channel). 2730 channel: If given, only respond to CC messages on this channel. 2731 Uses the same numbering convention as ``cc_map()``. 2732 ``None`` matches any channel (default). 2733 output_channel: Override the output channel. ``None`` uses the 2734 incoming channel. Uses the same numbering convention as ``pattern()``. 2735 mode: Dispatch mode: 2736 2737 - ``"instant"`` *(default)* — send immediately on the MIDI input 2738 callback thread. Lowest latency (~1–5 ms). Instant forwards are 2739 **not** recorded when recording is enabled. 2740 - ``"queued"`` — inject into the sequencer event queue and send at 2741 the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards 2742 **are** recorded when recording is enabled. 2743 2744 Example: 2745 ```python 2746 comp.midi_input("Arturia KeyStep") 2747 2748 # CC 1 → CC 1 (identity, instant) 2749 comp.cc_forward(1, "cc") 2750 2751 # CC 1 → pitch bend on channel 1, queued (recordable) 2752 comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") 2753 2754 # CC 1 → CC 74, custom channel 2755 comp.cc_forward(1, "cc:74", output_channel=2) 2756 2757 # Custom transform — remap CC range 0–127 to CC 74 range 40–100 2758 import subsequence.midi as midi 2759 comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) 2760 2761 # Forward AND map to data simultaneously — both active on the same CC 2762 comp.cc_map(1, "mod_wheel") 2763 comp.cc_forward(1, "cc:74") 2764 ``` 2765 """ 2766 2767 if not 0 <= cc <= 127: 2768 raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") 2769 2770 if mode not in ('instant', 'queued'): 2771 raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'") 2772 2773 resolved_in_channel = self._resolve_channel(channel) if channel is not None else None 2774 resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None 2775 2776 transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) 2777 2778 self._cc_forwards.append({ 2779 'cc': cc, 2780 'channel': resolved_in_channel, 2781 'output_channel': resolved_out_channel, 2782 'mode': mode, 2783 'transform': transform, 2784 'input_device': input_device, # resolved to int index in _run() 2785 'output_device': output_device, # resolved to int index in _run() 2786 })
Forward an incoming MIDI CC to the MIDI output in real-time.
Unlike cc_map() which writes incoming CC values to composition.data
for use at pattern rebuild time, cc_forward() routes the signal
directly to the MIDI output — bypassing the pattern cycle entirely.
Both cc_map() and cc_forward() may be registered for the same CC
number; they operate independently.
Arguments:
- cc: Incoming CC number to listen for (0–127).
output: What to send. Either a preset string:
"cc"— identity forward, same CC number and value."cc:N"— forward as CC number N (e.g."cc:74")."pitchwheel"— scale 0–127 to -8192..8191 and send as pitch bend.
Or a callable with signature
(value: int, channel: int) -> Optional[mido.Message]. Return a fully 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(). 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")
2789 def live (self, port: int = 5555) -> None: 2790 2791 """ 2792 Enable the live coding eval server. 2793 2794 This allows you to connect to a running composition using the 2795 `subsequence.live_client` REPL and hot-swap pattern code or 2796 modify variables in real-time. 2797 2798 Security: 2799 The server executes arbitrary Python in this process — it is **not** a 2800 sandbox. It binds to localhost only and is opt-in, but any process on 2801 the same machine that can reach the port gains full code execution here. 2802 Do not enable it on shared or multi-user hosts, and never expose the 2803 port to a network. 2804 2805 Parameters: 2806 port: The TCP port to listen on (default 5555). 2807 """ 2808 2809 self._live_server = subsequence.live_server.LiveServer(self, port=port) 2810 self._is_live = True
Enable the live coding eval server.
This allows you to connect to a running composition using the
subsequence.live_client REPL and hot-swap pattern code or
modify variables in real-time.
Security:
The server executes arbitrary Python in this process — it is not a sandbox. It binds to localhost only and is opt-in, but any process on the same machine that can reach the port gains full code execution here. Do not enable it on shared or multi-user hosts, and never expose the port to a network.
Arguments:
- port: The TCP port to listen on (default 5555).
2812 def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None: 2813 2814 """Watch a Python file and reload it into the composition on every save. 2815 2816 The watched file is exec'd into a namespace with ``composition`` and 2817 ``subsequence`` available. ``@composition.pattern`` decorators inside 2818 the file hot-swap their corresponding running patterns in place; 2819 patterns whose function bodies have been deleted from the file are 2820 unregistered automatically on the next reload (notes stopped, 2821 removed from the running-pattern set). 2822 2823 An **initial synchronous load** happens here — if the file has a 2824 ``SyntaxError`` or doesn't exist at this moment, the exception 2825 propagates so the user knows immediately. Subsequent reloads 2826 happen on the composition's event loop and tolerate transient 2827 errors (logged, skipped). 2828 2829 Call BEFORE ``composition.play()``. Reloads happen on the 2830 composition's event loop, so all mutations are thread-safe. 2831 2832 See the "Live coding via file watching" section of the README for 2833 the recommended wrapper-script + live-file split. 2834 2835 Parameters: 2836 path: Path to the Python file to watch. 2837 poll_interval: Seconds between ``mtime`` polls (default 0.25 s). 2838 2839 Example:: 2840 2841 # live_init.py — runs once 2842 composition = subsequence.Composition(bpm=120, key="E") 2843 composition.harmony(style="aeolian_minor") 2844 composition.watch("live_patterns.py") 2845 composition.play() 2846 """ 2847 2848 # Required for the decorator hot-swap path to fire on re-decoration. 2849 self._is_live = True 2850 2851 # Detect the single-file workflow: if watch() is called from inside 2852 # the very file being watched, the outer Python script execution will 2853 # already register the patterns (the decorators sit at module level 2854 # below ``watch(__file__)``). In that case, _load_initial's re-exec 2855 # would double-register every pattern, so skip it. For the two-file 2856 # workflow (path != caller's __file__) the initial exec is essential 2857 # — it's the only way the watched file's patterns ever reach the 2858 # composition. 2859 caller_file = self._caller_module_file() 2860 self_watch = False 2861 if caller_file is not None: 2862 try: 2863 self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() 2864 except OSError: 2865 self_watch = False 2866 2867 self._live_reloader = subsequence.live_reloader.LiveReloader( 2868 composition = self, 2869 path = path, 2870 poll_interval = poll_interval, 2871 skip_initial_exec = self_watch, 2872 ) 2873 self._live_reloader.start()
Watch a Python file and reload it into the composition on every save.
The watched file is exec'd into a namespace with composition and
subsequence available. @composition.pattern decorators inside
the file hot-swap their corresponding running patterns in place;
patterns whose function bodies have been deleted from the file are
unregistered automatically on the next reload (notes stopped,
removed from the running-pattern set).
An initial synchronous load happens here — if the file has a
SyntaxError or doesn't exist at this moment, the exception
propagates so the user knows immediately. Subsequent reloads
happen on the composition's event loop and tolerate transient
errors (logged, skipped).
Call BEFORE composition.play(). Reloads happen on the
composition's event loop, so all mutations are thread-safe.
See the "Live coding via file watching" section of the README for the recommended wrapper-script + live-file split.
Arguments:
- path: Path to the Python file to watch.
- poll_interval: Seconds between
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()
2892 def load_patterns ( 2893 self, 2894 source: str, 2895 source_label: str = "<string>", 2896 ) -> None: 2897 2898 """Compile and apply a pattern-source string to the composition. 2899 2900 Equivalent to one ``watch()`` reload triggered by save, but with the 2901 source presented in-memory rather than on disk. Useful for web / 2902 REST handlers that accept pattern uploads from a trusted contributor, 2903 or for one-shot session loads with no file backing. 2904 2905 Behaviour mirrors ``watch()``: 2906 * The source is exec'd into a fresh namespace with ``composition`` 2907 and ``subsequence`` in scope. 2908 * ``@composition.pattern`` decorators in the source hot-swap their 2909 corresponding running patterns in place. 2910 * Patterns currently running but **not** declared in the source are 2911 unregistered — the source is treated as the full new truth. 2912 * If the composition is already playing, the swap happens on the 2913 event loop thread; the call blocks until it completes. 2914 * If the composition has not yet called ``play()``, the source runs 2915 on the caller's thread; decorators populate ``_pending_patterns`` 2916 and ``play()`` picks them up in the usual way. 2917 2918 Errors are raised so the caller can act on them: 2919 * ``SyntaxError`` if ``source`` fails to compile. 2920 * The exception raised inside ``exec()`` for any runtime error. 2921 * ``RuntimeError`` if called from inside the composition's own 2922 event loop thread (would deadlock — see Threading below). 2923 2924 In either failure case, existing composition state is preserved — 2925 the diff-and-unregister phase is skipped if exec raised, so a 2926 half-broken upload cannot tear down working patterns. 2927 2928 Threading: 2929 Designed to be called from a thread DIFFERENT from the 2930 composition's event loop — typically a web-handler worker. 2931 Cannot be called from inside the loop itself (a pattern 2932 callback, an asyncio task spawned by the composition). From 2933 there, ``await composition._apply_source_async(...)`` directly. 2934 2935 SECURITY WARNING: ``exec()`` is not sandboxed. The source has full 2936 Python access in this process. Only pass source from trusted 2937 senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, 2938 ``exit``, ``quit``) prevents calls that would stall the event loop; 2939 it is not a security boundary. 2940 2941 Parameters: 2942 source: Python source declaring ``@composition.pattern`` 2943 functions. 2944 source_label: Identifier used in compile errors and tracebacks 2945 (appears as the filename in ``SyntaxError`` and ``__file__``- 2946 style traceback lines). Default ``"<string>"``. 2947 """ 2948 2949 # Required for the decorator hot-swap path to fire on re-decoration. 2950 self._is_live = True 2951 2952 # Compile on the caller's thread so SyntaxError comes back fast, 2953 # before any cross-thread scheduling. 2954 compiled = compile(source, source_label, "exec") 2955 namespace = self._build_live_namespace(source_label = source_label) 2956 2957 loop = self._sequencer._event_loop 2958 2959 if loop is not None and loop.is_running(): 2960 2961 # Refuse to deadlock: calling load_patterns() from inside the 2962 # composition's own event loop (e.g. from a pattern callback or 2963 # an asyncio task spawned by the composition) would have us 2964 # block waiting for a coroutine that can only run when this 2965 # thread yields. Tell the caller exactly what to do instead. 2966 try: 2967 current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() 2968 except RuntimeError: 2969 current_loop = None 2970 2971 if current_loop is loop: 2972 raise RuntimeError( 2973 "load_patterns() cannot be called from inside the composition's " 2974 "event loop thread — it would deadlock waiting for the " 2975 "scheduled coroutine to run on the very thread that's blocked. " 2976 "From a worker thread, call it normally. From an async " 2977 "coroutine already on the loop, " 2978 "`await composition._apply_source_async(compile(source, label, 'exec'), " 2979 "composition._build_live_namespace())` instead." 2980 ) 2981 2982 # Composition is playing — mutation must happen on the loop thread. 2983 # future.result() blocks the caller until the coroutine finishes 2984 # and re-raises any exception it threw. 2985 future = asyncio.run_coroutine_threadsafe( 2986 self._apply_source_async(compiled, namespace), 2987 loop = loop, 2988 ) 2989 future.result() 2990 2991 else: 2992 # Pre-play: no event loop yet. Decorators populate 2993 # _pending_patterns; play() graduates them in the usual way. 2994 # Diff-and-unregister is unnecessary here — nothing is running. 2995 exec(compiled, namespace)
Compile and apply a pattern-source string to the composition.
Equivalent to one watch() reload triggered by save, but with the
source presented in-memory rather than on disk. Useful for web /
REST handlers that accept pattern uploads from a trusted contributor,
or for one-shot session loads with no file backing.
Behaviour mirrors watch():
- The source is exec'd into a fresh namespace with
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>".
3098 def osc (self, receive_port: int = 9000, send_port: int = 9001, send_host: str = "127.0.0.1", receive_host: str = "0.0.0.0") -> None: 3099 3100 """ 3101 Enable bi-directional Open Sound Control (OSC). 3102 3103 Subsequence will listen for commands (like `/bpm` or `/mute`) and 3104 broadcast its internal state (like `/chord` or `/bar`) over UDP. 3105 3106 Parameters: 3107 receive_port: Port to listen for incoming OSC messages (default 9000). 3108 send_port: Port to send state updates to (default 9001). 3109 send_host: The IP address to send updates to (default "127.0.0.1"). 3110 receive_host: Interface to listen on (default "0.0.0.0" — all 3111 interfaces, so external OSC controllers on the LAN can reach it). 3112 The listener can change tempo, mute patterns, and write data, so on 3113 an untrusted network restrict it with ``receive_host="127.0.0.1"``. 3114 """ 3115 3116 self._osc_server = subsequence.osc.OscServer( 3117 self, 3118 receive_port = receive_port, 3119 send_port = send_port, 3120 send_host = send_host, 3121 receive_host = receive_host 3122 )
Enable bi-directional Open Sound Control (OSC).
Subsequence will listen for commands (like /bpm or /mute) and
broadcast its internal state (like /chord or /bar) over UDP.
Arguments:
- receive_port: Port to listen for incoming OSC messages (default 9000).
- send_port: Port to send state updates to (default 9001).
- send_host: The IP address to send updates to (default "127.0.0.1").
- receive_host: Interface to listen on (default "0.0.0.0" — all
interfaces, so external OSC controllers on the LAN can reach it).
The listener can change tempo, mute patterns, and write data, so on
an untrusted network restrict it with
receive_host="127.0.0.1".
3124 def osc_map (self, address: str, handler: typing.Callable) -> None: 3125 3126 """ 3127 Register a custom OSC handler. 3128 3129 Must be called after :meth:`osc` has been configured. 3130 3131 Parameters: 3132 address: OSC address pattern to match (e.g. ``"/my/param"``). 3133 handler: Callable invoked with ``(address, *args)`` when a 3134 matching message arrives. 3135 3136 Example:: 3137 3138 composition.osc() 3139 3140 def on_intensity (address, value): 3141 composition.data["intensity"] = float(value) 3142 3143 composition.osc_map("/intensity", on_intensity) 3144 """ 3145 3146 if self._osc_server is None: 3147 raise RuntimeError("Call composition.osc() before composition.osc_map()") 3148 3149 self._osc_server.map(address, handler)
Register a custom OSC handler.
Must be called after osc() has been configured.
Arguments:
- address: OSC address pattern to match (e.g.
"/my/param"). - handler: Callable invoked with
(address, *args)when a matching message arrives.
Example::
composition.osc()
def on_intensity (address, value):
composition.data["intensity"] = float(value)
composition.osc_map("/intensity", on_intensity)
3151 def set_bpm (self, bpm: float) -> None: 3152 3153 """ 3154 Instantly change the tempo. 3155 3156 Parameters: 3157 bpm: The new tempo in beats per minute. 3158 3159 When Ableton Link is active, this proposes the new tempo to the Link 3160 network instead of applying it locally. The network-authoritative tempo 3161 is picked up on the next pulse. 3162 """ 3163 3164 self._sequencer.set_bpm(bpm) 3165 3166 if not self.is_clock_following and self._link_quantum is None: 3167 self.bpm = bpm
Instantly change the tempo.
Arguments:
- bpm: The new tempo in beats per minute.
When Ableton Link is active, this proposes the new tempo to the Link network instead of applying it locally. The network-authoritative tempo is picked up on the next pulse.
3169 def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None: 3170 3171 """ 3172 Smoothly ramp the tempo to a target value over a number of bars. 3173 3174 Parameters: 3175 bpm: Target tempo in beats per minute. 3176 bars: Duration of the transition in bars. 3177 shape: Easing curve name. Defaults to ``"linear"``. 3178 ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- 3179 sounding tempo changes. See :mod:`subsequence.easing` for all 3180 available shapes. 3181 3182 Example: 3183 ```python 3184 # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve 3185 comp.target_bpm(140, bars=8, shape="ease_in_out") 3186 ``` 3187 3188 Note: 3189 Ignored while Ableton Link is active — the shared session tempo is 3190 authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. 3191 """ 3192 3193 self._sequencer.set_target_bpm(bpm, bars, shape)
Smoothly ramp the tempo to a target value over a number of bars.
Arguments:
- bpm: Target tempo in beats per minute.
- bars: Duration of the transition in bars.
- shape: Easing curve name. Defaults to
"linear"."ease_in_out"or"s_curve"are recommended for natural- sounding tempo changes. 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.
3195 def live_info (self) -> typing.Dict[str, typing.Any]: 3196 3197 """ 3198 Return a dictionary containing the current state of the composition. 3199 3200 Includes BPM, key, current bar, active section, current chord, 3201 running patterns, and custom data. 3202 """ 3203 3204 section_info = None 3205 if self._form_state is not None: 3206 section = self._form_state.get_section_info() 3207 if section is not None: 3208 section_info = { 3209 "name": section.name, 3210 "bar": section.bar, 3211 "bars": section.bars, 3212 "progress": section.progress 3213 } 3214 3215 chord_name = None 3216 sounding_chord = self.current_chord() 3217 if sounding_chord is not None: 3218 chord_name = sounding_chord.name() 3219 3220 pattern_list = [] 3221 channel_offset = 0 if self._zero_indexed_channels else 1 3222 for name, pat in self._running_patterns.items(): 3223 pattern_list.append({ 3224 "name": name, 3225 "channel": pat.channel + channel_offset, 3226 "length": pat.length, 3227 "cycle": pat._cycle_count, 3228 "muted": pat._muted, 3229 "tweaks": dict(pat._tweaks) 3230 }) 3231 3232 return { 3233 "bpm": self._sequencer.current_bpm, 3234 "key": self.key, 3235 "bar": self._builder_bar, 3236 "section": section_info, 3237 "chord": chord_name, 3238 "patterns": pattern_list, 3239 "input_device": self._input_device, 3240 "clock_follow": self.is_clock_following, 3241 "data": self.data 3242 }
Return a dictionary containing the current state of the composition.
Includes BPM, key, current bar, active section, current chord, running patterns, and custom data.
3244 def mute (self, name: str) -> None: 3245 3246 """ 3247 Mute a running pattern by name. 3248 3249 The pattern continues to 'run' and increment its cycle count in 3250 the background, but it will not produce any MIDI notes until unmuted. 3251 3252 Parameters: 3253 name: The function name of the pattern to mute. 3254 """ 3255 3256 if name not in self._running_patterns: 3257 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3258 3259 self._running_patterns[name]._muted = True 3260 logger.info(f"Muted pattern: {name}")
Mute a running pattern by name.
The pattern continues to 'run' and increment its cycle count in the background, but it will not produce any MIDI notes until unmuted.
Arguments:
- name: The function name of the pattern to mute.
3262 def unmute (self, name: str) -> None: 3263 3264 """ 3265 Unmute a previously muted pattern. 3266 """ 3267 3268 if name not in self._running_patterns: 3269 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3270 3271 self._running_patterns[name]._muted = False 3272 logger.info(f"Unmuted pattern: {name}")
Unmute a previously muted pattern.
3274 def unregister (self, name: str) -> None: 3275 3276 """Fully remove a running pattern from rotation. 3277 3278 Unlike ``mute()`` (which keeps the pattern alive but silent), 3279 ``unregister()`` tears the pattern down entirely. It sets 3280 ``pattern._removed = True`` so the sequencer's reschedule loop 3281 skips re-adding it on the next pulse; sends ``note_off`` for any 3282 of the pattern's currently-sounding notes on the primary 3283 destination AND on every mirror destination (so drones and 3284 sustaining notes stop immediately); and removes the entry from 3285 ``_running_patterns`` so it no longer appears in ``live_info()``, 3286 the terminal grid, or any other consumer that enumerates running 3287 patterns. 3288 3289 Already-queued events in the sequencer's event queue play out — 3290 note_offs are paired with their note_ons at queue time, so notes 3291 end at their natural duration; only drones rely on the targeted 3292 ``_stop_pattern_notes`` pass. 3293 3294 Idempotent: silently logs a ``debug`` and returns if the pattern 3295 is already absent. Useful from both the live REPL 3296 (``composition.live()``) and the file watcher 3297 (``composition.watch()``), which calls this for any pattern 3298 removed from the watched file between reloads. 3299 3300 Parameters: 3301 name: Function name of the pattern to remove. 3302 """ 3303 3304 if name not in self._running_patterns: 3305 logger.debug(f"unregister() no-op: pattern '{name}' not running") 3306 return 3307 3308 pattern = self._running_patterns[name] 3309 3310 # Mark for removal first so the reschedule loop sees the flag even if 3311 # it fires concurrently with the note-off pass below. 3312 pattern._removed = True 3313 3314 # Stop sustaining notes (including drones) on every destination this 3315 # pattern outputs to. Fire-and-forget across threads via the event 3316 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 3317 if self._sequencer._event_loop is not None: 3318 asyncio.run_coroutine_threadsafe( 3319 self._sequencer._stop_pattern_notes(pattern), 3320 loop = self._sequencer._event_loop, 3321 ) 3322 3323 def _finalise_removal () -> None: 3324 self._running_patterns.pop(name, None) 3325 3326 # Forget any pending (not-yet-graduated) declaration too, so a 3327 # later live reload cannot resurrect the pattern. 3328 self._pending_patterns = [ 3329 pending for pending in self._pending_patterns 3330 if pending.builder_fn.__name__ != name 3331 ] 3332 3333 logger.info(f"Unregistered pattern: {name}") 3334 3335 # The running-patterns dict is iterated by the display, web UI, and 3336 # reschedule loop on the event loop thread — mutate it there when this 3337 # call arrives from another thread (e.g. the live TCP server). 3338 loop = self._sequencer._event_loop 3339 3340 try: 3341 on_loop = loop is not None and asyncio.get_running_loop() is loop 3342 except RuntimeError: 3343 on_loop = False 3344 3345 if loop is not None and loop.is_running() and not on_loop: 3346 loop.call_soon_threadsafe(_finalise_removal) 3347 else: 3348 _finalise_removal()
Fully remove a running pattern from rotation.
Unlike mute() (which keeps the pattern alive but silent),
unregister() tears the pattern down entirely. It sets
pattern._removed = True so the sequencer's reschedule loop
skips re-adding it on the next pulse; sends note_off for any
of the pattern's currently-sounding notes on the primary
destination AND on every mirror destination (so drones and
sustaining notes stop immediately); and removes the entry from
_running_patterns so it no longer appears in live_info(),
the terminal grid, or any other consumer that enumerates running
patterns.
Already-queued events in the sequencer's event queue play out —
note_offs are paired with their note_ons at queue time, so notes
end at their natural duration; only drones rely on the targeted
_stop_pattern_notes pass.
Idempotent: silently logs a debug and returns if the pattern
is already absent. Useful from both the live REPL
(composition.live()) and the file watcher
(composition.watch()), which calls this for any pattern
removed from the watched file between reloads.
Arguments:
- name: Function name of the pattern to remove.
3350 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 3351 3352 """ 3353 Add a mirror destination to a running pattern. 3354 3355 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 3356 event the pattern emits will also be sent to ``(device, channel)``, 3357 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 3358 — calling with the same destination twice does not double-fan; calling 3359 again with a different ``drum_note_map`` re-points it in place. 3360 3361 Parameters: 3362 name: Function name of the pattern to mirror. 3363 device: Output device index (the integer returned from 3364 ``midi_output()``; 0 = primary device). 3365 channel: MIDI channel using this composition's numbering convention 3366 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 3367 drum_note_map: Optional per-destination drum map. When set, mirrored 3368 drum hits are re-resolved by name through it, so a named voice 3369 lands on this device's own note number — see the README 3370 "MIDI mirroring" section. 3371 3372 Bandwidth: each mirror adds another full copy of the pattern's events. 3373 See the README "MIDI mirroring" section for the tradeoffs. 3374 """ 3375 3376 if name not in self._running_patterns: 3377 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3378 3379 resolved_channel = self._resolve_channel(channel) 3380 prefix = (device, resolved_channel) 3381 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 3382 3383 pattern = self._running_patterns[name] 3384 3385 # Mirror-to-self check: comparing the (device, channel) prefix against the 3386 # live pattern's resolved destination. Unlike the decorator path this is 3387 # always concrete. 3388 if prefix == (pattern.device, pattern.channel): 3389 logger.warning( 3390 f"Mirror destination {prefix} matches '{name}'s primary destination " 3391 f"— every event will double-fire on this (device, channel). This is almost " 3392 f"certainly unintended." 3393 ) 3394 3395 # Idempotent on (device, channel): replace any existing entry for the same 3396 # destination (so its map can be re-pointed), else append. 3397 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 3398 if existing_index is None: 3399 pattern.mirrors.append(entry) 3400 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 3401 elif pattern.mirrors[existing_index] != entry: 3402 pattern.mirrors[existing_index] = entry 3403 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 3404 else: 3405 logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}")
Add a mirror destination to a running pattern.
Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone
event the pattern emits will also be sent to (device, channel),
starting from the next cycle rebuild. Idempotent on (device, channel)
— calling with the same destination twice does not double-fan; calling
again with a different drum_note_map re-points it in place.
Arguments:
- name: Function name of the pattern to mirror.
- device: Output device index (the integer returned from
midi_output(); 0 = primary device). - channel: MIDI channel using this composition's numbering convention
(1-16 by default; 0-15 if
zero_indexed_channels=True). - drum_note_map: Optional per-destination drum map. When set, mirrored drum hits are re-resolved by name through it, so a named voice lands on this device's own note number — see the README "MIDI mirroring" section.
Bandwidth: each mirror adds another full copy of the pattern's events. See the README "MIDI mirroring" section for the tradeoffs.
3407 def unmirror (self, name: str, device: int, channel: int) -> None: 3408 3409 """ 3410 Remove a single mirror destination from a running pattern. 3411 3412 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 3413 ignored. Idempotent: silently does nothing if the destination is not 3414 currently mirrored. The change applies on the next cycle rebuild. 3415 """ 3416 3417 if name not in self._running_patterns: 3418 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3419 3420 resolved_channel = self._resolve_channel(channel) 3421 prefix = (device, resolved_channel) 3422 3423 pattern = self._running_patterns[name] 3424 3425 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 3426 if len(filtered) != len(pattern.mirrors): 3427 pattern.mirrors[:] = filtered 3428 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 3429 else: 3430 logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors")
Remove a single mirror destination from a running pattern.
Matches on (device, channel) only — any attached drum_note_map is
ignored. Idempotent: silently does nothing if the destination is not
currently mirrored. The change applies on the next cycle rebuild.
3432 def unmirror_all (self, name: str) -> None: 3433 3434 """ 3435 Remove every mirror destination from a running pattern. 3436 """ 3437 3438 if name not in self._running_patterns: 3439 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3440 3441 pattern = self._running_patterns[name] 3442 3443 if pattern.mirrors: 3444 pattern.mirrors.clear() 3445 logger.info(f"All mirrors cleared on pattern: {name}")
Remove every mirror destination from a running pattern.
3447 def tweak (self, name: str, **kwargs: typing.Any) -> None: 3448 3449 """Override parameters for a running pattern. 3450 3451 Values set here are available inside the pattern's builder 3452 function via ``p.param()``. They persist across rebuilds 3453 until explicitly changed or cleared. Changes take effect 3454 on the next rebuild cycle. 3455 3456 Parameters: 3457 name: The function name of the pattern. 3458 **kwargs: Parameter names and their new values. 3459 3460 Example (from the live REPL):: 3461 3462 composition.tweak("bass", pitches=[48, 52, 55, 60]) 3463 """ 3464 3465 if name not in self._running_patterns: 3466 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3467 3468 self._running_patterns[name]._tweaks.update(kwargs) 3469 logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}")
Override parameters for a running pattern.
Values set here are available inside the pattern's builder
function via p.param(). They persist across rebuilds
until explicitly changed or cleared. Changes take effect
on the next rebuild cycle.
Arguments:
- name: The function name of the pattern.
- **kwargs: Parameter names and their new values.
Example (from the live REPL)::
composition.tweak("bass", pitches=[48, 52, 55, 60])
3471 def clear_tweak (self, name: str, *param_names: str) -> None: 3472 3473 """Remove tweaked parameters from a running pattern. 3474 3475 If no parameter names are given, all tweaks for the pattern 3476 are cleared and every ``p.param()`` call reverts to its 3477 default. 3478 3479 Parameters: 3480 name: The function name of the pattern. 3481 *param_names: Specific parameter names to clear. If 3482 omitted, all tweaks are removed. 3483 """ 3484 3485 if name not in self._running_patterns: 3486 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3487 3488 if not param_names: 3489 self._running_patterns[name]._tweaks.clear() 3490 logger.info(f"Cleared all tweaks for pattern '{name}'") 3491 else: 3492 for param_name in param_names: 3493 self._running_patterns[name]._tweaks.pop(param_name, None) 3494 logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}")
Remove tweaked parameters from a running pattern.
If no parameter names are given, all tweaks for the pattern
are cleared and every p.param() call reverts to its
default.
Arguments:
- name: The function name of the pattern.
- *param_names: Specific parameter names to clear. If omitted, all tweaks are removed.
3496 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 3497 3498 """Return a copy of the current tweaks for a running pattern. 3499 3500 Parameters: 3501 name: The function name of the pattern. 3502 """ 3503 3504 if name not in self._running_patterns: 3505 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3506 3507 return dict(self._running_patterns[name]._tweaks)
Return a copy of the current tweaks for a running pattern.
Arguments:
- name: The function name of the pattern.
3509 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 3510 3511 """ 3512 Register a custom function to run on a repeating beat-based cycle. 3513 3514 Subsequence automatically runs synchronous functions in a thread pool 3515 so they don't block the timing-critical MIDI clock. Async functions 3516 are run directly on the event loop. 3517 3518 Parameters: 3519 fn: The function to call. 3520 cycle_beats: How often to call it (e.g., 4 = every bar). 3521 reschedule_lookahead: How far in advance to schedule the next call. 3522 wait_for_initial: If True, run the function once during startup 3523 and wait for it to complete before playback begins. This 3524 ensures ``composition.data`` is populated before patterns 3525 first build. Implies ``defer=True`` for the repeating 3526 schedule. 3527 defer: If True, skip the pulse-0 fire and defer the first 3528 repeating call to just before the second cycle boundary. 3529 3530 Raises: 3531 RuntimeError: If called after ``play()`` has started — scheduled 3532 tasks register at startup, so a late registration would be 3533 silently ignored otherwise. 3534 """ 3535 3536 if self._sequencer.running: 3537 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 3538 3539 self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer))
Register a custom function to run on a repeating beat-based cycle.
Subsequence automatically runs synchronous functions in a thread pool so they don't block the timing-critical MIDI clock. Async functions are run directly on the event loop.
Arguments:
- fn: The function to call.
- cycle_beats: How often to call it (e.g., 4 = every bar).
- reschedule_lookahead: How far in advance to schedule the next call.
- wait_for_initial: If True, run the function once during startup
and wait for it to complete before playback begins. This
ensures
composition.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.
3541 def form ( 3542 self, 3543 sections: typing.Union[ 3544 typing.List[typing.Tuple[str, int]], 3545 typing.Iterator[typing.Tuple[str, int]], 3546 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 3547 ], 3548 loop: bool = False, 3549 start: typing.Optional[str] = None 3550 ) -> None: 3551 3552 """ 3553 Define the structure (sections) of the composition. 3554 3555 You can define form in three ways: 3556 1. **Graph (Dict)**: Dynamic transitions based on weights. 3557 2. **Sequence (List)**: A fixed order of sections. 3558 3. **Generator**: A Python generator that yields `(name, bars)` pairs. 3559 3560 Parameters: 3561 sections: The form definition (Dict, List, or Generator). 3562 loop: Whether to cycle back to the start (List mode only). 3563 start: The section to start with (Graph mode only). 3564 3565 Example: 3566 ```python 3567 # A simple pop structure 3568 comp.form([ 3569 ("verse", 8), 3570 ("chorus", 8), 3571 ("verse", 8), 3572 ("chorus", 16) 3573 ]) 3574 ``` 3575 """ 3576 3577 # Seed FormState at form() time (per-call salt) so build-time walks — 3578 # the frozen clones form_freeze will take — are deterministic without 3579 # play(); the play-time stream is re-dealt name-keyed in _run(). 3580 self._form_count += 1 3581 3582 self._form_state = subsequence.form_state.FormState( 3583 sections, 3584 loop = loop, 3585 start = start, 3586 rng = self._stream(f"form:{self._form_count}") 3587 )
Define the structure (sections) of the composition.
You can define form in three ways:
- Graph (Dict): Dynamic transitions based on weights.
- Sequence (List): A fixed order of sections.
- Generator: A Python generator that yields
(name, bars)pairs.
Arguments:
- sections: The form definition (Dict, List, or Generator).
- loop: Whether to cycle back to the start (List mode only).
- start: The section to start with (Graph mode only).
Example:
# A simple pop structure comp.form([ ("verse", 8), ("chorus", 8), ("verse", 8), ("chorus", 16) ])
3642 def pattern ( 3643 self, 3644 channel: int, 3645 beats: typing.Optional[float] = None, 3646 bars: typing.Optional[float] = None, 3647 steps: typing.Optional[float] = None, 3648 step_duration: typing.Optional[float] = None, 3649 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 3650 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 3651 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 3652 reschedule_lookahead: float = 1, 3653 voice_leading: bool = False, 3654 device: subsequence.midi_utils.DeviceId = None, 3655 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3656 ) -> typing.Callable: 3657 3658 """ 3659 Register a function as a repeating MIDI pattern. 3660 3661 The decorated function will be called once per cycle to 'rebuild' its 3662 content. This allows for generative logic that evolves over time. 3663 3664 Two ways to specify pattern length: 3665 3666 - **Duration mode** (default): use ``beats=`` or ``bars=``. 3667 The grid defaults to sixteenth-note resolution. 3668 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 3669 The grid equals the step count, so ``p.hit_steps()`` indices map 3670 directly to steps. 3671 3672 Parameters: 3673 channel: MIDI channel. By default uses 1-based numbering (1-16). 3674 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 3675 0-based numbering (0-15), matching the raw MIDI protocol, instead. 3676 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 3677 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 3678 steps: Step count for step mode. Requires ``step_duration=``. 3679 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 3680 Requires ``steps=``. 3681 drum_note_map: Optional mapping for drum instruments. 3682 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 3683 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 3684 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 3685 14-bit parameter numbers (0–16383). Enables string-based names 3686 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 3687 device-specific dictionary (e.g. Sequential Take 5's 3688 ``Osc1FreqFine`` → 9). 3689 reschedule_lookahead: Beats in advance to compute the next cycle. 3690 voice_leading: If True, chords in this pattern will automatically 3691 use inversions that minimize voice movement. 3692 mirrors: Optional list of additional ``(device, channel)`` destinations 3693 to duplicate every event from this pattern onto. Notes, CCs, pitch 3694 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 3695 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 3696 ``device`` is the integer index returned by ``midi_output()`` (0 = 3697 primary). ``channel`` follows this composition's channel-numbering 3698 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 3699 3700 Example: 3701 ```python 3702 @comp.pattern(channel=1, beats=4) 3703 def chords (p): 3704 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 3705 3706 @comp.pattern(channel=1, bars=2) 3707 def long_phrase (p): 3708 ... 3709 3710 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 3711 def riff (p): 3712 p.sequence(steps=[0, 1, 3, 5], pitches=60) 3713 ``` 3714 """ 3715 3716 channel = self._resolve_channel(channel) 3717 3718 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 3719 3720 # Resolve device string name to index if possible now; otherwise store 3721 # the raw DeviceId and resolve it in _run() once all devices are open. 3722 resolved_device: subsequence.midi_utils.DeviceId = device 3723 3724 # Mirror-to-self check is only reliable when the primary device is a 3725 # concrete integer at decoration time. ``None`` resolves to device 0 3726 # downstream, so we treat it as 0 here too. Strings are deferred to 3727 # ``_run()`` and we skip the check for them. 3728 primary: typing.Optional[typing.Tuple[int, int]] 3729 if isinstance(resolved_device, str): 3730 primary = None 3731 else: 3732 primary = (resolved_device if resolved_device is not None else 0, channel) 3733 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 3734 3735 def decorator (fn: typing.Callable) -> typing.Callable: 3736 3737 """ 3738 Wrap the builder function and register it as a pending pattern. 3739 During live sessions, hot-swap an existing pattern's builder instead. 3740 """ 3741 3742 # Record this declaration so the live-reload deletion diff knows the 3743 # pattern is still present in the source (see _apply_source_async). 3744 self._declared_names.add(fn.__name__) 3745 3746 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 3747 if self._is_live and fn.__name__ in self._running_patterns: 3748 running = self._running_patterns[fn.__name__] 3749 running._builder_fn = fn 3750 running._wants_chord = _fn_has_parameter(fn, "chord") 3751 logger.info(f"Hot-swapped pattern: {fn.__name__}") 3752 return fn 3753 3754 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 3755 # duplicate means two scheduled copies sharing one stream with 3756 # only one reachable by name. Warn loudly at registration. 3757 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 3758 logger.warning( 3759 f"Duplicate pattern name '{fn.__name__}': both copies will be " 3760 f"scheduled, they share one seeded stream, and only one is " 3761 f"reachable by name — rename one of them." 3762 ) 3763 3764 pending = _PendingPattern( 3765 builder_fn = fn, 3766 channel = channel, # already resolved to 0-indexed 3767 length = beat_length, 3768 default_grid = default_grid, 3769 drum_note_map = drum_note_map, 3770 cc_name_map = cc_name_map, 3771 nrpn_name_map = nrpn_name_map, 3772 reschedule_lookahead = reschedule_lookahead, 3773 voice_leading = voice_leading, 3774 # For int/None: resolve immediately. For str: store 0 as 3775 # placeholder; _resolve_pending_devices() fixes it in _run(). 3776 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 3777 raw_device = resolved_device, 3778 mirrors = resolved_mirrors, 3779 ) 3780 3781 self._pending_patterns.append(pending) 3782 3783 return fn 3784 3785 return decorator
Register a function as a repeating MIDI pattern.
The decorated function will be called once per cycle to 'rebuild' its content. This allows for generative logic that evolves over time.
Two ways to specify pattern length:
- Duration mode (default): use
beats=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.
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)
3787 def layer ( 3788 self, 3789 *builder_fns: typing.Callable, 3790 channel: int, 3791 beats: typing.Optional[float] = None, 3792 bars: typing.Optional[float] = None, 3793 steps: typing.Optional[float] = None, 3794 step_duration: typing.Optional[float] = None, 3795 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 3796 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 3797 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 3798 reschedule_lookahead: float = 1, 3799 voice_leading: bool = False, 3800 device: subsequence.midi_utils.DeviceId = None, 3801 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3802 ) -> None: 3803 3804 """ 3805 Combine multiple functions into a single MIDI pattern. 3806 3807 This is useful for composing complex patterns out of reusable 3808 building blocks (e.g., a 'kick' function and a 'snare' function). 3809 3810 See ``pattern()`` for the full description of ``beats``, ``bars``, 3811 ``steps``, and ``step_duration``. 3812 3813 Parameters: 3814 builder_fns: One or more pattern builder functions. 3815 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 3816 beats: Duration in beats (quarter notes). 3817 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 3818 steps: Step count for step mode. Requires ``step_duration=``. 3819 step_duration: Duration of one step in beats. Requires ``steps=``. 3820 drum_note_map: Optional mapping for drum instruments. 3821 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 3822 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 3823 parameter numbers. 3824 reschedule_lookahead: Beats in advance to compute the next cycle. 3825 voice_leading: If True, chords use smooth voice leading. 3826 mirrors: Optional list of additional ``(device, channel)`` destinations 3827 to duplicate every event onto. See ``pattern()`` for details. 3828 """ 3829 3830 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 3831 3832 # Resolve channel up-front so the mirror-to-self check has the canonical 3833 # primary form to compare against. 3834 resolved_channel = self._resolve_channel(channel) 3835 3836 # See pattern() for the same comment about None / str handling. 3837 primary: typing.Optional[typing.Tuple[int, int]] 3838 if isinstance(device, str): 3839 primary = None 3840 else: 3841 primary = (device if device is not None else 0, resolved_channel) 3842 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 3843 3844 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 3845 3846 if wants_chord: 3847 3848 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 3849 3850 for fn in builder_fns: 3851 if _fn_has_parameter(fn, "chord"): 3852 fn(p, chord) 3853 else: 3854 fn(p) 3855 3856 else: 3857 3858 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 3859 3860 for fn in builder_fns: 3861 fn(p) 3862 3863 # Give the merged builder a stable, unique name derived from its 3864 # components so multiple layer() calls don't all register under 3865 # "merged_builder" and collide in _running_patterns (which made 3866 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 3867 # appear in a Python identifier, so this never clashes with a real 3868 # pattern function's name. 3869 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 3870 merged_name = base_name 3871 suffix = 2 3872 3873 # Two layers with the same components (e.g. on different saves of a 3874 # live file) must map to the same names pass-over-pass, while two 3875 # DIFFERENT layers sharing components in one pass must not collide. 3876 while merged_name in self._declared_names: 3877 merged_name = f"{base_name}#{suffix}" 3878 suffix += 1 3879 3880 merged_builder.__name__ = merged_name 3881 3882 # Record the declaration for the live-reload deletion diff, and hot-swap 3883 # in place when this layer is already running so a reload picks up edits 3884 # to the component functions without losing the pattern's cycle count, 3885 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 3886 self._declared_names.add(merged_builder.__name__) 3887 3888 if self._is_live and merged_builder.__name__ in self._running_patterns: 3889 running = self._running_patterns[merged_builder.__name__] 3890 running._builder_fn = merged_builder 3891 running._wants_chord = wants_chord 3892 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 3893 return 3894 3895 pending = _PendingPattern( 3896 builder_fn = merged_builder, 3897 channel = resolved_channel, # already resolved to 0-indexed above 3898 length = beat_length, 3899 default_grid = default_grid, 3900 drum_note_map = drum_note_map, 3901 cc_name_map = cc_name_map, 3902 nrpn_name_map = nrpn_name_map, 3903 reschedule_lookahead = reschedule_lookahead, 3904 voice_leading = voice_leading, 3905 mirrors = resolved_mirrors, 3906 device = 0 if (device is None or isinstance(device, str)) else device, 3907 raw_device = device, 3908 ) 3909 3910 self._pending_patterns.append(pending)
Combine multiple functions into a single MIDI pattern.
This is useful for composing complex patterns out of reusable building blocks (e.g., a 'kick' function and a 'snare' function).
See pattern() for the full description of beats, bars,
steps, and step_duration.
Arguments:
- builder_fns: One or more pattern builder functions.
- channel: MIDI channel (1-16, or 0-15 with
zero_indexed_channels=True). - beats: Duration in beats (quarter notes).
- bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
- steps: Step count for step mode. Requires
step_duration=. - step_duration: Duration of one step in beats. Requires
steps=. - drum_note_map: Optional mapping for drum instruments.
- cc_name_map: Optional mapping of CC names to MIDI CC numbers.
- nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit parameter numbers.
- reschedule_lookahead: Beats in advance to compute the next cycle.
- voice_leading: If True, chords use smooth voice leading.
- mirrors: Optional list of additional
(device, channel)destinations to duplicate every event onto. Seepattern()for details.
3912 def chords ( 3913 self, 3914 *, 3915 channel: int, 3916 progression: subsequence.progressions.ProgressionSource, 3917 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 3918 bars: typing.Optional[float] = None, 3919 beats: typing.Optional[float] = None, 3920 voicing: subsequence.progressions.VoicingSpec = (3, 4), 3921 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 3922 detached: typing.Optional[float] = None, 3923 root: int = 60, 3924 key: typing.Optional[str] = None, 3925 seed: typing.Optional[int] = None, 3926 device: subsequence.midi_utils.DeviceId = None, 3927 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 3928 ) -> subsequence.progressions.Progression: 3929 3930 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 3931 3932 The one-call form of ``p.progression()`` — it registers a pattern on 3933 *channel* that plays *progression* across *bars* (or *beats*), each chord 3934 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 3935 the chords change). It needs no ``composition.harmony()`` call and, with an 3936 explicit chord list or a ``key=``, no composition key either — so a 3937 drums-plus-one-chord-part sketch stays simple. 3938 3939 The progression is realised once, up front, and the same timeline plays every 3940 cycle (a stable phrase). That timeline is returned so you can see exactly what 3941 was chosen — ``print(comp.chords(...))``. 3942 3943 Parameters: 3944 channel: MIDI channel for the chord part. 3945 progression: A chord-graph style name to generate from, or an explicit list 3946 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 3947 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 3948 or ``between(low, high, step=...)``. See ``p.progression()``. 3949 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 3950 composition's time signature. 3951 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 3952 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 3953 detached: Beats of silence before each next chord (``duration = length - detached``). 3954 root: MIDI root the voicings are centred on (e.g. 48 = C3). 3955 key: Key for a generated progression; defaults to the composition key. 3956 seed: Seed for the (otherwise fixed) realisation; defaults to the 3957 composition seed, so the part is reproducible. 3958 device: Optional output-device override. 3959 mirrors: Optional additional ``(device, channel)`` destinations. 3960 3961 Returns: 3962 The realised :class:`~subsequence.progressions.Progression`. 3963 """ 3964 3965 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 3966 resolved_channel = self._resolve_channel(channel) 3967 resolved_key = key if key is not None else self.key 3968 3969 rng = random.Random(seed if seed is not None else self._seed) 3970 timeline = subsequence.progressions.realize( 3971 source = progression, 3972 harmonic_rhythm = harmonic_rhythm, 3973 key = resolved_key, 3974 length = beat_length, 3975 rng = rng, 3976 scale = self.scale or "ionian", 3977 ) 3978 3979 captured_root = root 3980 captured_velocity = velocity 3981 captured_detached = detached 3982 captured_voicing = voicing 3983 3984 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 3985 3986 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 3987 3988 for chord, start, length in timeline: 3989 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 3990 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 3991 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 3992 3993 # Unique, stable name so multiple chord parts don't collide in 3994 # _running_patterns — including two parts on the SAME channel, which 3995 # get a deterministic #2/#3 suffix in declaration order. 3996 base_name = f"chords@ch{resolved_channel}" 3997 chords_name = base_name 3998 suffix = 2 3999 4000 while chords_name in self._declared_names: 4001 chords_name = f"{base_name}#{suffix}" 4002 suffix += 1 4003 4004 chords_builder.__name__ = chords_name 4005 self._declared_names.add(chords_name) 4006 4007 primary: typing.Optional[typing.Tuple[int, int]] 4008 if isinstance(device, str): 4009 primary = None 4010 else: 4011 primary = (device if device is not None else 0, resolved_channel) 4012 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4013 4014 self._declared_names.add(chords_builder.__name__) 4015 4016 if self._is_live and chords_builder.__name__ in self._running_patterns: 4017 running = self._running_patterns[chords_builder.__name__] 4018 running._builder_fn = chords_builder 4019 running._wants_chord = False 4020 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 4021 return timeline 4022 4023 pending = _PendingPattern( 4024 builder_fn = chords_builder, 4025 channel = resolved_channel, 4026 length = beat_length, 4027 default_grid = default_grid, 4028 drum_note_map = None, 4029 reschedule_lookahead = 1, 4030 voice_leading = False, 4031 mirrors = resolved_mirrors, 4032 device = 0 if (device is None or isinstance(device, str)) else device, 4033 raw_device = device, 4034 ) 4035 self._pending_patterns.append(pending) 4036 return timeline
Declare a self-contained chord part: a progression at a chosen harmonic rhythm.
The one-call form of p.progression() — it registers a pattern on
channel that plays progression across bars (or beats), each chord
lasting a length drawn from harmonic_rhythm (the musical term for how often
the chords change). It needs no composition.harmony() call and, with an
explicit chord list or a key=, no composition key either — so a
drums-plus-one-chord-part sketch stays simple.
The progression is realised once, up front, and the same timeline plays every
cycle (a stable phrase). That timeline is returned so you can see exactly what
was chosen — print(comp.chords(...)).
Arguments:
- channel: MIDI channel for the chord part.
- progression: A chord-graph style name to generate from, or an explicit list
of chords (
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.
4038 def phrase_part ( 4039 self, 4040 *, 4041 channel: int, 4042 part: typing.Optional[str] = None, 4043 root: int = 60, 4044 bars: typing.Optional[float] = None, 4045 beats: typing.Optional[float] = None, 4046 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 4047 fit: typing.Optional[float] = None, 4048 device: subsequence.midi_utils.DeviceId = None, 4049 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4050 ) -> None: 4051 4052 """Declare a part that plays each section's bound Motif/Phrase. 4053 4054 The one-call consumer for :meth:`section_motifs` — it registers a 4055 pattern on *channel* that walks whatever value is bound to the 4056 current section for *part* (stateless position from the cycle 4057 counter, via ``p.phrase()``). A section with no binding for the 4058 part is **silent** for that part — bind material or don't; no 4059 fallback guessing. 4060 4061 Parameters: 4062 channel: MIDI channel for the part. 4063 part: The part label to read from the registry (``None`` = the 4064 unlabelled binding). 4065 root: Register anchor for degree resolution. 4066 bars / beats: Cycle length of the part (defaults to 4 beats); 4067 the phrase is sliced one cycle window at a time. 4068 velocity: Optional override applied to every note. 4069 fit: Passed through (active with the melody engine stage). 4070 device: Optional output-device override. 4071 mirrors: Optional additional ``(device, channel)`` destinations. 4072 4073 Example:: 4074 4075 composition.section_motifs("verse", verse_line, part="lead") 4076 composition.section_motifs("chorus", chorus_line, part="lead") 4077 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 4078 """ 4079 4080 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 4081 resolved_channel = self._resolve_channel(channel) 4082 4083 captured_part = part 4084 captured_root = root 4085 captured_velocity = velocity 4086 captured_fit = fit 4087 4088 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 4089 4090 """Walk the current section's bound value (silent when unbound).""" 4091 4092 value = p.section_motif(captured_part) 4093 4094 if value is None: 4095 return # unbound section: silence for this part, by design 4096 4097 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 4098 4099 # Unique, stable name so multiple phrase parts don't collide — 4100 # including two parts on the SAME channel (deterministic #2/#3 4101 # suffixes in declaration order, the chords() convention). 4102 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 4103 phrase_name = base_name 4104 suffix = 2 4105 4106 while phrase_name in self._declared_names: 4107 phrase_name = f"{base_name}#{suffix}" 4108 suffix += 1 4109 4110 phrase_builder.__name__ = phrase_name 4111 self._declared_names.add(phrase_name) 4112 4113 primary: typing.Optional[typing.Tuple[int, int]] 4114 if isinstance(device, str): 4115 primary = None 4116 else: 4117 primary = (device if device is not None else 0, resolved_channel) 4118 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4119 4120 if self._is_live and phrase_builder.__name__ in self._running_patterns: 4121 running = self._running_patterns[phrase_builder.__name__] 4122 running._builder_fn = phrase_builder 4123 running._wants_chord = False 4124 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 4125 return 4126 4127 pending = _PendingPattern( 4128 builder_fn = phrase_builder, 4129 channel = resolved_channel, 4130 length = beat_length, 4131 default_grid = default_grid, 4132 drum_note_map = None, 4133 reschedule_lookahead = 1, 4134 voice_leading = False, 4135 mirrors = resolved_mirrors, 4136 device = 0 if (device is None or isinstance(device, str)) else device, 4137 raw_device = device, 4138 ) 4139 self._pending_patterns.append(pending)
Declare a part that plays each section's bound Motif/Phrase.
The one-call consumer for section_motifs() — it registers a
pattern on channel that walks whatever value is bound to the
current section for part (stateless position from the cycle
counter, via p.phrase()). A section with no binding for the
part is silent for that part — bind material or don't; no
fallback guessing.
Arguments:
- channel: MIDI channel for the part.
- part: The part label to read from the registry (
None= the unlabelled binding). - root: Register anchor for degree resolution.
- bars / beats: Cycle length of the part (defaults to 4 beats); the phrase is sliced one cycle window at a time.
- velocity: Optional override applied to every note.
- fit: Passed through (active with the melody engine stage).
- device: Optional output-device override.
- mirrors: Optional additional
(device, channel)destinations.
Example::
composition.section_motifs("verse", verse_line, part="lead")
composition.section_motifs("chorus", chorus_line, part="lead")
composition.phrase_part(channel=4, part="lead", root=72, bars=2)
4141 def trigger ( 4142 self, 4143 fn: typing.Callable, 4144 channel: int, 4145 beats: typing.Optional[float] = None, 4146 bars: typing.Optional[float] = None, 4147 steps: typing.Optional[float] = None, 4148 step_duration: typing.Optional[float] = None, 4149 quantize: float = 0, 4150 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4151 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4152 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4153 chord: bool = False, 4154 device: subsequence.midi_utils.DeviceId = None, 4155 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4156 ) -> None: 4157 4158 """ 4159 Trigger a one-shot pattern immediately or on a quantized boundary. 4160 4161 This is useful for real-time response to sensors, OSC messages, or other 4162 external events. The builder function is called immediately with a fresh 4163 PatternBuilder, and the generated events are injected into the queue at 4164 the specified quantize boundary. 4165 4166 The builder function has the same API as a ``@composition.pattern`` 4167 decorated function and can use all PatternBuilder methods: ``p.note()``, 4168 ``p.euclidean()``, ``p.arpeggio()``, and so on. 4169 4170 See ``pattern()`` for the full description of ``beats``, ``bars``, 4171 ``steps``, and ``step_duration``. Default is 1 beat. 4172 4173 Parameters: 4174 fn: The pattern builder function (same signature as ``@comp.pattern``). 4175 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4176 beats: Duration in beats (quarter notes, default 1). 4177 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4178 steps: Step count for step mode. Requires ``step_duration=``. 4179 step_duration: Duration of one step in beats. Requires ``steps=``. 4180 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 4181 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 4182 constants from ``subsequence.constants.durations``. 4183 drum_note_map: Optional drum name mapping for this pattern. 4184 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4185 nrpn_name_map: Optional mapping of NRPN parameter names to 4186 14-bit parameter numbers. 4187 chord: If ``True``, the builder function receives the current chord as 4188 a second parameter (same as ``@composition.pattern``). 4189 mirrors: Optional list of additional ``(device, channel)`` destinations 4190 to fire this one-shot onto in parallel with the primary destination. 4191 4192 Example: 4193 ```python 4194 # Immediate single note (channels are 1-16 by default) 4195 composition.trigger( 4196 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 4197 channel=1 4198 ) 4199 4200 # Quantized fill (next bar) — channel 10 is the GM drum channel 4201 import subsequence.constants.durations as dur 4202 composition.trigger( 4203 lambda p: p.euclidean("snare", pulses=7, velocity=90), 4204 channel=10, 4205 drum_note_map=gm_drums.GM_DRUM_MAP, 4206 quantize=dur.WHOLE 4207 ) 4208 4209 # With chord context — the builder receives the chord as a second 4210 # argument when chord=True. 4211 composition.trigger( 4212 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 4213 channel=1, 4214 quantize=dur.QUARTER, 4215 chord=True 4216 ) 4217 ``` 4218 """ 4219 4220 # Resolve channel numbering 4221 resolved_channel = self._resolve_channel(channel) 4222 4223 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 4224 4225 # Resolve device index — for trigger() this is always concrete by call time, 4226 # so the mirror-to-self check has the full primary tuple available. 4227 resolved_device_idx = self._resolve_device_id(device) 4228 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 4229 4230 # Create a temporary Pattern 4231 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 4232 4233 # Create a PatternBuilder 4234 builder = subsequence.pattern_builder.PatternBuilder( 4235 pattern=pattern, 4236 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 4237 drum_note_map=drum_note_map, 4238 cc_name_map=cc_name_map, 4239 nrpn_name_map=nrpn_name_map, 4240 section=self._form_state.get_section_info() if self._form_state else None, 4241 bar=self._builder_bar, 4242 conductor=self.conductor, 4243 rng=random.Random(), # Fresh random state for each trigger 4244 tweaks={}, 4245 default_grid=default_grid, 4246 data=self.data, 4247 held_notes=self._sequencer._held_notes 4248 ) 4249 4250 # Call the builder function 4251 try: 4252 4253 current_chord = self.current_chord() if chord else None 4254 4255 if current_chord is not None: 4256 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 4257 fn(builder, injected) 4258 4259 else: 4260 fn(builder) 4261 4262 except Exception: 4263 logger.exception("Error in trigger builder — pattern will be silent") 4264 return 4265 4266 # Calculate the start pulse based on quantize 4267 current_pulse = self._sequencer.pulse_count 4268 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 4269 4270 if quantize == 0: 4271 # Immediate: use current pulse 4272 start_pulse = current_pulse 4273 4274 else: 4275 # Quantize to the next multiple of (quantize * pulses_per_beat) 4276 quantize_pulses = int(quantize * pulses_per_beat) 4277 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 4278 4279 # Schedule the pattern for one-shot execution 4280 try: 4281 # Probe only: raises RuntimeError when not on the event loop. 4282 asyncio.get_running_loop() 4283 asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) 4284 4285 except RuntimeError: 4286 # Not on the event loop — hand the coroutine to the loop thread. 4287 if self._sequencer._event_loop is not None: 4288 asyncio.run_coroutine_threadsafe( 4289 self._sequencer.schedule_pattern(pattern, start_pulse), 4290 loop=self._sequencer._event_loop 4291 ) 4292 else: 4293 logger.warning("trigger() called before playback started; pattern ignored")
Trigger a one-shot pattern immediately or on a quantized boundary.
This is useful for real-time response to sensors, OSC messages, or other external events. The builder function is called immediately with a fresh PatternBuilder, and the generated events are injected into the queue at the specified quantize boundary.
The builder function has the same API as a @composition.pattern
decorated function and can use all PatternBuilder methods: p.note(),
p.euclidean(), p.arpeggio(), and so on.
See pattern() for the full description of beats, bars,
steps, and step_duration. Default is 1 beat.
Arguments:
- fn: The pattern builder function (same signature as
@comp.pattern). - channel: MIDI channel (1-16, or 0-15 with
zero_indexed_channels=True). - beats: Duration in beats (quarter notes, default 1).
- bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
- steps: Step count for step mode. Requires
step_duration=. - step_duration: Duration of one step in beats. Requires
steps=. - quantize: Snap the trigger to a beat boundary:
0= immediate (default),1= next beat (quarter note),4= next bar. 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 )
4295 @property 4296 def is_clock_following (self) -> bool: 4297 4298 """True if either the primary or any additional device is following external clock.""" 4299 4300 return self._clock_follow or any(cf for _, _, cf in self._additional_inputs)
True if either the primary or any additional device is following external clock.
4303 def play (self) -> None: 4304 4305 """ 4306 Start the composition. 4307 4308 This call blocks until the program is interrupted (e.g., via Ctrl+C). 4309 It initializes the MIDI hardware, launches the background sequencer, 4310 and begins playback. 4311 """ 4312 4313 try: 4314 asyncio.run(self._run()) 4315 4316 except KeyboardInterrupt: 4317 pass
Start the composition.
This call blocks until the program is interrupted (e.g., via Ctrl+C). It initializes the MIDI hardware, launches the background sequencer, and begins playback.
4320 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 4321 4322 """Render the composition to a MIDI file without real-time playback. 4323 4324 Runs the sequencer as fast as possible (no timing delays) and stops 4325 when the first active limit is reached. The result is saved as a 4326 standard MIDI file that can be imported into any DAW. 4327 4328 All patterns, scheduled callbacks, and harmony logic run exactly as 4329 they would during live playback — BPM transitions, generative fills, 4330 and probabilistic gates all work in render mode. The only difference 4331 is that time is simulated rather than wall-clock driven. 4332 4333 Parameters: 4334 bars: Number of bars to render, or ``None`` for no bar limit 4335 (default ``None``). When both *bars* and *max_minutes* are 4336 active, playback stops at whichever limit is reached first. 4337 filename: Output MIDI filename (default ``"render.mid"``). 4338 max_minutes: Safety cap on the length of rendered MIDI in minutes 4339 (default ``60.0``). Pass ``None`` to disable the time 4340 cap — you must then provide an explicit *bars* value. 4341 4342 Raises: 4343 ValueError: If both *bars* and *max_minutes* are ``None``, which 4344 would produce an infinite render. 4345 4346 Examples: 4347 ```python 4348 # Default: renders up to 60 minutes of MIDI content. 4349 composition.render() 4350 4351 # Render exactly 64 bars (time cap still active as backstop). 4352 composition.render(bars=64, filename="demo.mid") 4353 4354 # Render up to 5 minutes of an infinite generative composition. 4355 composition.render(max_minutes=5, filename="five_min.mid") 4356 4357 # Remove the time cap — must supply bars instead. 4358 composition.render(bars=128, max_minutes=None, filename="long.mid") 4359 ``` 4360 """ 4361 4362 if bars is None and max_minutes is None: 4363 raise ValueError( 4364 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 4365 "Passing both as None would produce an infinite render." 4366 ) 4367 4368 self._sequencer.recording = True 4369 self._sequencer.record_filename = filename 4370 self._sequencer.render_mode = True 4371 self._sequencer.render_bars = bars if bars is not None else 0 4372 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 4373 asyncio.run(self._run())
Render the composition to a MIDI file without real-time playback.
Runs the sequencer as fast as possible (no timing delays) and stops when the first active limit is reached. The result is saved as a standard MIDI file that can be imported into any DAW.
All patterns, scheduled callbacks, and harmony logic run exactly as they would during live playback — BPM transitions, generative fills, and probabilistic gates all work in render mode. The only difference is that time is simulated rather than wall-clock driven.
Arguments:
- bars: Number of bars to render, or
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")
337@dataclasses.dataclass(frozen=True) 338class Motif: 339 340 """ 341 An immutable musical figure: timed note events + control gestures + a length in beats. 342 343 Construct via the classmethods (:meth:`degrees`, :meth:`notes`, 344 :meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture 345 constructors, or :meth:`from_events`) rather than positionally. 346 ``length`` is explicit — a trailing rest is meaningful. 347 """ 348 349 events: typing.Tuple[MotifEvent, ...] 350 length: float 351 controls: typing.Tuple[ControlEvent, ...] = () 352 353 def __post_init__ (self) -> None: 354 355 """Validate, and normalise both streams to canonical order.""" 356 357 if self.length < 0: 358 raise ValueError(f"Motif length must be non-negative — got {self.length}") 359 360 object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key))) 361 object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key))) 362 363 # ── constructors ──────────────────────────────────────────────────── 364 365 @classmethod 366 def empty (cls) -> "Motif": 367 368 """The empty motif (zero events, zero length) — the identity for ``then``.""" 369 370 return cls(events=(), length=0.0) 371 372 @classmethod 373 def from_events ( 374 cls, 375 events: typing.Iterable[MotifEvent], 376 length: typing.Optional[float] = None, 377 controls: typing.Iterable[ControlEvent] = (), 378 ) -> "Motif": 379 380 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 381 382 events = tuple(events) 383 controls = tuple(controls) 384 385 return cls( 386 events = events, 387 length = _computed_length(events, controls) if length is None else length, 388 controls = controls, 389 ) 390 391 @classmethod 392 def _from_sequence ( 393 cls, 394 pitches: typing.List[PitchSpec], 395 beats: typing.Optional[typing.List[float]], 396 velocities: typing.Any, 397 durations: typing.Any, 398 probabilities: typing.Any, 399 length: typing.Optional[float], 400 ) -> "Motif": 401 402 """Shared core: one event per element, None = rest (slot still advances).""" 403 404 n = len(pitches) 405 onsets = list(beats) if beats is not None else [float(i) for i in range(n)] 406 407 if len(onsets) != n: 408 raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match") 409 410 velocity_list = _expand("velocities", velocities, n) 411 duration_list = _expand("durations", durations, n) 412 probability_list = _expand("probabilities", probabilities, n) 413 414 events = tuple( 415 MotifEvent( 416 beat = float(onsets[i]), 417 pitch = pitches[i], 418 velocity = velocity_list[i], 419 duration = float(duration_list[i]), 420 probability = float(probability_list[i]), 421 ) 422 for i in range(n) 423 if pitches[i] is not None 424 ) 425 426 return cls( 427 events = events, 428 length = _computed_length(events, ()) if length is None else float(length), 429 ) 430 431 @classmethod 432 def degrees ( 433 cls, 434 degrees: typing.List[typing.Union[int, Degree, None]], 435 beats: typing.Optional[typing.List[float]] = None, 436 velocities: typing.Any = _DEFAULT_VELOCITY, 437 durations: typing.Any = 1.0, 438 probabilities: typing.Any = 1.0, 439 length: typing.Optional[float] = None, 440 ) -> "Motif": 441 442 """ 443 A melody written as 1-based scale degrees, one per beat by default. 444 445 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 446 rest (the beat slot still advances), or :class:`Degree` for octave/ 447 chromatic detail. Resolved against key + scale at placement. 448 Durations default to a full beat (each note holds its slot). 449 """ 450 451 converted: typing.List[PitchSpec] = [] 452 453 for element in degrees: 454 if isinstance(element, int): 455 if element > _MAX_PLAUSIBLE_DEGREE: 456 raise ValueError( 457 f"Degree {element} is implausibly large — scale degrees are 1-based " 458 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 459 ) 460 converted.append(Degree(element)) 461 elif isinstance(element, Degree) or element is None: 462 converted.append(element) 463 else: 464 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 465 466 return cls._from_sequence(converted, beats, velocities, durations, probabilities, length) 467 468 @classmethod 469 def notes ( 470 cls, 471 notes: typing.List[typing.Union[int, None]], 472 beats: typing.Optional[typing.List[float]] = None, 473 velocities: typing.Any = _DEFAULT_VELOCITY, 474 durations: typing.Any = 1.0, 475 probabilities: typing.Any = 1.0, 476 length: typing.Optional[float] = None, 477 ) -> "Motif": 478 479 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 480 481 for element in notes: 482 if not (isinstance(element, int) or element is None): 483 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 484 485 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length) 486 487 @classmethod 488 def hits ( 489 cls, 490 pitch: typing.Union[int, str], 491 beats: typing.List[float], 492 length: typing.Optional[float] = None, 493 velocities: typing.Any = _DEFAULT_VELOCITY, 494 durations: typing.Any = 0.1, 495 probabilities: typing.Any = 1.0, 496 ) -> "Motif": 497 498 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 499 500 return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length) 501 502 @classmethod 503 def steps ( 504 cls, 505 steps: typing.List[int], 506 pitches: typing.Any, 507 velocities: typing.Any = _DEFAULT_VELOCITY, 508 durations: typing.Any = 0.1, 509 probabilities: typing.Any = 1.0, 510 step_duration: float = 0.25, 511 length: typing.Optional[float] = None, 512 ) -> "Motif": 513 514 """ 515 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 516 grid indices (sixteenths by default), ``pitches`` a scalar or 517 parallel list of MIDI ints or drum names. 518 """ 519 520 n = len(steps) 521 pitch_list = _expand("pitches", pitches, n) 522 onsets = [s * step_duration for s in steps] 523 524 if length is None and n: 525 length = float(math.ceil((max(steps) + 1) * step_duration)) 526 527 return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length) 528 529 @classmethod 530 def euclidean ( 531 cls, 532 pulses: int, 533 steps: int, 534 pitch: typing.Union[int, str], 535 length: float = 4.0, 536 velocities: typing.Any = _DEFAULT_VELOCITY, 537 durations: typing.Any = 0.1, 538 probabilities: typing.Any = 1.0, 539 ) -> "Motif": 540 541 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 542 543 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 544 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 545 step_duration = length / steps 546 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 547 548 return cls._from_sequence( 549 [pitch] * len(onsets), 550 onsets, 551 velocities, durations, probabilities, length, 552 ) 553 554 # ── control-gesture constructors (mirror the pattern_midi verbs) ──── 555 556 @classmethod 557 def _control_writes ( 558 cls, 559 signal: ControlSignal, 560 values: typing.List[float], 561 beats: typing.List[float], 562 length: typing.Optional[float], 563 probabilities: typing.Any = 1.0, 564 ) -> "Motif": 565 566 """Shared core for discrete control writes.""" 567 568 if len(values) != len(beats): 569 raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match") 570 571 probability_list = _expand("probabilities", probabilities, len(values)) 572 573 controls = tuple( 574 ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i])) 575 for i in range(len(values)) 576 ) 577 578 return cls( 579 events = (), 580 length = _computed_length((), controls) if length is None else float(length), 581 controls = controls, 582 ) 583 584 @classmethod 585 def _control_ramp ( 586 cls, 587 signal: ControlSignal, 588 start: float, 589 end: float, 590 beat_start: float, 591 beat_end: typing.Optional[float], 592 shape: typing.Union[str, "subsequence.easing.EasingFn"], 593 length: typing.Optional[float], 594 probability: float = 1.0, 595 ) -> "Motif": 596 597 """Shared core for shaped control ramps.""" 598 599 if beat_end is None: 600 if length is None: 601 raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)") 602 beat_end = float(length) 603 604 if beat_end <= beat_start: 605 raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})") 606 607 controls = ( 608 ControlEvent( 609 beat = float(beat_start), 610 signal = signal, 611 start = float(start), 612 end = float(end), 613 span = float(beat_end) - float(beat_start), 614 shape = shape, 615 probability = probability, 616 ), 617 ) 618 619 return cls( 620 events = (), 621 length = float(math.ceil(beat_end)) if length is None else float(length), 622 controls = controls, 623 ) 624 625 @classmethod 626 def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 627 628 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 629 630 return cls._control_writes(CC(control), list(values), list(beats), length, probabilities) 631 632 @classmethod 633 def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 634 635 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 636 637 return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability) 638 639 @classmethod 640 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 641 642 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 643 644 return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities) 645 646 @classmethod 647 def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 648 649 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 650 651 return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability) 652 653 @classmethod 654 def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 655 656 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 657 658 return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 659 660 @classmethod 661 def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 662 663 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 664 665 return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 666 667 @classmethod 668 def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 669 670 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 671 672 return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 673 674 @classmethod 675 def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 676 677 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 678 679 return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 680 681 @classmethod 682 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 683 684 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 685 686 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities) 687 688 @classmethod 689 def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 690 691 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 692 693 return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability) 694 695 # ── the algebra ───────────────────────────────────────────────────── 696 697 def then (self, other: "Motif") -> "Motif": 698 699 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 700 701 if not isinstance(other, Motif): 702 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 703 704 return Motif( 705 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 706 length = self.length + other.length, 707 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 708 ) 709 710 @classmethod 711 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 712 713 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 714 715 result = cls.empty() 716 717 for m in motifs: 718 result = result.then(m) 719 720 return result 721 722 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 723 724 """ 725 Parallel merge (the spelled form of ``&``): event union, length = max. 726 727 No implicit tiling — a short gesture stacked under a long figure 728 plays once. Phrase operands flatten first. 729 """ 730 731 if isinstance(other, Phrase): 732 merged = other.flatten() 733 elif isinstance(other, Motif): 734 merged = other 735 else: 736 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 737 738 return Motif( 739 events = self.events + merged.events, 740 length = max(self.length, merged.length), 741 controls = self.controls + merged.controls, 742 ) 743 744 def slice (self, start: float, end: float) -> "Motif": 745 746 """ 747 A window onto the motif, on its own authority: events starting outside 748 are dropped; durations and ramp spans truncate at the cut (a truncated 749 ramp ends at its interpolated cut value). Beats shift so the window 750 starts at 0. 751 """ 752 753 if end <= start: 754 raise ValueError(f"slice end ({end}) must be after start ({start})") 755 756 events = tuple( 757 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 758 for e in self.events 759 if start <= e.beat < end 760 ) 761 762 controls = [] 763 764 for c in self.controls: 765 if not (start <= c.beat < end): 766 continue 767 if c.end is not None and c.beat + c.span > end: 768 kept = end - c.beat 769 controls.append(dataclasses.replace( 770 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 771 )) 772 else: 773 controls.append(dataclasses.replace(c, beat=c.beat - start)) 774 775 return Motif(events=events, length=end - start, controls=tuple(controls)) 776 777 def __add__ (self, other: typing.Any) -> "Phrase": 778 779 """``a + b`` — sequential: a two-segment Phrase (segmentation preserved).""" 780 781 if isinstance(other, Motif): 782 return Phrase((self, other)) 783 784 return NotImplemented 785 786 def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]: 787 788 """``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty.""" 789 790 if not isinstance(count, int): 791 return NotImplemented 792 if count < 0: 793 raise ValueError(f"Repetition count must be non-negative — got {count}") 794 if count == 0: 795 return Motif.empty() 796 if count == 1: 797 return self 798 799 return Phrase((self,) * count) 800 801 __rmul__ = __mul__ 802 803 def __and__ (self, other: typing.Any) -> "Motif": 804 805 """``a & b`` — parallel merge; the spelled form is :meth:`stack`.""" 806 807 if isinstance(other, (Motif, Phrase)): 808 return self.stack(other) 809 810 return NotImplemented 811 812 # ── transforms (pure; control gestures ride time, ignore pitch) ───── 813 814 def reverse (self) -> "Motif": 815 816 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 817 818 events = tuple( 819 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 820 for e in self.events 821 ) 822 controls = tuple( 823 dataclasses.replace( 824 c, 825 beat = max(0.0, self.length - c.beat - c.span), 826 start = c.start if c.end is None else c.end, 827 end = c.end if c.end is None else c.start, 828 ) 829 for c in self.controls 830 ) 831 832 return Motif(events=events, length=self.length, controls=controls) 833 834 def rotate (self, beats: float) -> "Motif": 835 836 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 837 838 if self.length == 0: 839 return self 840 841 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 842 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 843 844 return Motif(events=events, length=self.length, controls=controls) 845 846 def stretch (self, factor: float) -> "Motif": 847 848 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 849 850 if factor <= 0: 851 raise ValueError(f"Stretch factor must be positive — got {factor}") 852 853 events = tuple( 854 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 855 for e in self.events 856 ) 857 controls = tuple( 858 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 859 for c in self.controls 860 ) 861 862 return Motif(events=events, length=self.length * factor, controls=controls) 863 864 def quantize (self, grid: float) -> "Motif": 865 866 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched).""" 867 868 if grid <= 0: 869 raise ValueError(f"Quantize grid must be positive — got {grid}") 870 871 events = tuple( 872 dataclasses.replace(e, beat=round(e.beat / grid) * grid) 873 for e in self.events 874 ) 875 876 return Motif(events=events, length=self.length, controls=self.controls) 877 878 def accent (self, beat: float, amount: int = 20) -> "Motif": 879 880 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 881 882 def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]: 883 if isinstance(velocity, tuple): 884 return (min(127, velocity[0] + amount), min(127, velocity[1] + amount)) 885 return min(127, velocity + amount) 886 887 events = tuple( 888 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 889 for e in self.events 890 ) 891 892 return Motif(events=events, length=self.length, controls=self.controls) 893 894 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif": 895 896 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 897 898 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 899 900 return Motif(events=events, length=self.length, controls=self.controls) 901 902 def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec: 903 904 """One varied pitch: a small melodic nudge that always changes the note. 905 906 Degrees move by scale steps, MIDI ints by semitones, chord tones by 907 index; an Approach's target is nudged. Drum names raise — a varied 908 drum is a different instrument, not a variation. 909 """ 910 911 if isinstance(pitch, Degree): 912 steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1] 913 return dataclasses.replace(pitch, step = rng.choice(steps)) 914 if isinstance(pitch, ChordTone): 915 indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1] 916 return ChordTone(rng.choice(indices), octave = pitch.octave) 917 if isinstance(pitch, Approach): 918 nudged = self._nudged_pitch(pitch.target, rng) 919 if not isinstance(nudged, (int, Degree, ChordTone)): 920 raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content") 921 return Approach(nudged) 922 if isinstance(pitch, int): 923 return pitch + rng.choice((-2, -1, 1, 2)) 924 925 raise TypeError( 926 f"vary() moves pitches — {type(pitch).__name__} content cannot vary " 927 "(a varied drum is a different instrument)" 928 ) 929 930 def vary ( 931 self, 932 notes: int = 1, 933 position: str = "end", 934 seed: typing.Optional[int] = None, 935 rng: typing.Optional[random.Random] = None, 936 ) -> "Motif": 937 938 """Replace a few pitches, preserving the rhythm — the smallest variation. 939 940 Rhythm, velocities, durations, rests, and control gestures are 941 untouched; only the chosen notes' pitches move (by a small melodic 942 nudge: scale steps for degrees, semitones for MIDI ints). 943 944 Parameters: 945 notes: How many pitched notes to vary (clamped to what exists). 946 position: Which notes — ``"end"`` (the tail, the default), 947 ``"start"``, or ``"anywhere"`` (drawn from the stream). 948 seed: Seed for the variation. A standalone vary without a seed 949 warns — module-level nondeterminism breaks live reload. 950 rng: An explicit random stream (overrides ``seed``; used by 951 recipe machinery). 952 953 Example: 954 ```python 955 answer = call.vary(notes=1, seed=4) # same figure, new tail note 956 ``` 957 """ 958 959 if notes < 0: 960 raise ValueError(f"notes must be at least 0, got {notes}") 961 if position not in ("end", "start", "anywhere"): 962 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 963 964 if rng is None: 965 if seed is None: 966 warnings.warn( 967 "vary() without seed= is nondeterministic — pass seed= so the " 968 "value survives live reload", 969 stacklevel = 2, 970 ) 971 rng = random.Random() 972 else: 973 rng = random.Random(seed) 974 975 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 976 count = min(notes, len(pitched_indices)) 977 978 if count == 0: 979 return self 980 981 if position == "end": 982 chosen = pitched_indices[-count:] 983 elif position == "start": 984 chosen = pitched_indices[:count] 985 else: 986 chosen = sorted(rng.sample(pitched_indices, count)) 987 988 events = list(self.events) 989 990 for index in chosen: 991 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng)) 992 993 return Motif(events = tuple(events), length = self.length, controls = self.controls) 994 995 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 996 997 """Call → response: re-aim the tail to a stable degree. 998 999 The classic consequent move — the figure repeats but its last pitched 1000 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1001 or a full ``Degree`` for register control). Everything else — 1002 rhythm, the other pitches, velocities, controls — is untouched. 1003 1004 Degree content only: absolute MIDI has no degrees to re-aim (build 1005 the call with ``motif([...])``), and drums raise. 1006 """ 1007 1008 target = to if isinstance(to, Degree) else Degree(int(to)) 1009 1010 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1011 1012 if not pitched_indices: 1013 return self 1014 1015 last = self.events[pitched_indices[-1]] 1016 1017 if not isinstance(last.pitch, Degree): 1018 raise TypeError( 1019 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1020 "content (build the call with motif([...]) for degree content)" 1021 ) 1022 1023 if isinstance(to, int): 1024 # Keep the call's register: only the step is re-aimed. 1025 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1026 1027 events = list(self.events) 1028 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1029 1030 return Motif(events = tuple(events), length = self.length, controls = self.controls) 1031 1032 def pitched (self, spec: PitchSpec) -> "Motif": 1033 1034 """ 1035 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1036 1037 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1038 tones; any other string is a drum name; ints are MIDI; Degree / 1039 ChordTone / Approach pass through. 1040 """ 1041 1042 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1043 spec = ChordTone(spec) 1044 1045 events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) 1046 1047 return Motif(events=events, length=self.length, controls=self.controls) 1048 1049 def rhythm (self) -> "Motif": 1050 1051 """ 1052 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1053 1054 Timing, velocities, durations, and probabilities survive; re-pitch 1055 with :meth:`pitched` before placement (placing a skeleton raises). 1056 """ 1057 1058 events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) 1059 1060 return Motif(events=events, length=self.length) 1061 1062 def onsets (self) -> typing.List[float]: 1063 1064 """The note onset beats, in order — ready for rhythm-first generation.""" 1065 1066 return [e.beat for e in self.events] 1067 1068 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1069 1070 """ 1071 Transpose pitched content; the keyword names the unit. 1072 1073 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1074 raises on absolute-MIDI or drum content; ``semitones=`` is the 1075 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1076 on both — a transposed drum name is a different instrument, not a 1077 transposition. 1078 """ 1079 1080 if (steps is None) == (semitones is None): 1081 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1082 1083 def move (pitch: PitchSpec) -> PitchSpec: 1084 1085 if pitch is None: 1086 return None 1087 1088 if isinstance(pitch, Approach): 1089 moved = move(pitch.target) 1090 if not isinstance(moved, (int, Degree, ChordTone)): 1091 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1092 return Approach(moved) 1093 1094 if steps is not None: 1095 if isinstance(pitch, Degree): 1096 return dataclasses.replace(pitch, step=pitch.step + steps) 1097 raise TypeError( 1098 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1099 f"has no degrees (use semitones= for MIDI ints)" 1100 ) 1101 1102 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1103 1104 if isinstance(pitch, int): 1105 return pitch + semitones 1106 if isinstance(pitch, Degree): 1107 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1108 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1109 1110 events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) 1111 1112 return Motif(events=events, length=self.length, controls=self.controls) 1113 1114 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1115 1116 """ 1117 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1118 degree content around a degree pivot (default: the first note's pitch). 1119 Drum motifs raise. 1120 """ 1121 1122 pitched_events = [e for e in self.events if e.pitch is not None] 1123 1124 if not pitched_events: 1125 return self 1126 1127 first = pitched_events[0].pitch 1128 1129 if pivot is None: 1130 if isinstance(first, int): 1131 pivot = first 1132 elif isinstance(first, Degree): 1133 pivot = first.step 1134 else: 1135 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1136 1137 def mirror (pitch: PitchSpec) -> PitchSpec: 1138 1139 if pitch is None: 1140 return None 1141 if isinstance(pitch, int): 1142 return 2 * pivot - pitch 1143 if isinstance(pitch, Degree): 1144 mirrored = 2 * pivot - pitch.step 1145 if mirrored < 1: 1146 raise ValueError( 1147 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1148 f"raise the pivot or use Degree octaves" 1149 ) 1150 return dataclasses.replace(pitch, step=mirrored, chroma=-pitch.chroma) 1151 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1152 1153 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events) 1154 1155 return Motif(events=events, length=self.length, controls=self.controls) 1156 1157 # ── description ───────────────────────────────────────────────────── 1158 1159 def describe (self) -> str: 1160 1161 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1162 1163 notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) 1164 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1165 1166 if self.controls: 1167 gestures = ", ".join(_control_label(c) for c in self.controls) 1168 parts.append(f"controls [{gestures}]") 1169 1170 return " ".join(parts) 1171 1172 def __str__ (self) -> str: 1173 1174 """Printable form (same as :meth:`describe`).""" 1175 1176 return self.describe()
An immutable musical figure: timed note events + control gestures + a length in beats.
Construct via the classmethods (degrees(), notes(),
hits(), steps(), euclidean(), the control-gesture
constructors, or from_events()) rather than positionally.
length is explicit — a trailing rest is meaningful.
365 @classmethod 366 def empty (cls) -> "Motif": 367 368 """The empty motif (zero events, zero length) — the identity for ``then``.""" 369 370 return cls(events=(), length=0.0)
The empty motif (zero events, zero length) — the identity for then.
372 @classmethod 373 def from_events ( 374 cls, 375 events: typing.Iterable[MotifEvent], 376 length: typing.Optional[float] = None, 377 controls: typing.Iterable[ControlEvent] = (), 378 ) -> "Motif": 379 380 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 381 382 events = tuple(events) 383 controls = tuple(controls) 384 385 return cls( 386 events = events, 387 length = _computed_length(events, controls) if length is None else length, 388 controls = controls, 389 )
Build a motif from explicit events (power use; length defaults to the next whole beat).
431 @classmethod 432 def degrees ( 433 cls, 434 degrees: typing.List[typing.Union[int, Degree, None]], 435 beats: typing.Optional[typing.List[float]] = None, 436 velocities: typing.Any = _DEFAULT_VELOCITY, 437 durations: typing.Any = 1.0, 438 probabilities: typing.Any = 1.0, 439 length: typing.Optional[float] = None, 440 ) -> "Motif": 441 442 """ 443 A melody written as 1-based scale degrees, one per beat by default. 444 445 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 446 rest (the beat slot still advances), or :class:`Degree` for octave/ 447 chromatic detail. Resolved against key + scale at placement. 448 Durations default to a full beat (each note holds its slot). 449 """ 450 451 converted: typing.List[PitchSpec] = [] 452 453 for element in degrees: 454 if isinstance(element, int): 455 if element > _MAX_PLAUSIBLE_DEGREE: 456 raise ValueError( 457 f"Degree {element} is implausibly large — scale degrees are 1-based " 458 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 459 ) 460 converted.append(Degree(element)) 461 elif isinstance(element, Degree) or element is None: 462 converted.append(element) 463 else: 464 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 465 466 return cls._from_sequence(converted, beats, velocities, durations, probabilities, length)
A melody written as 1-based scale degrees, one per beat by default.
Elements are ints (1 = tonic, 8 = tonic an octave up), None for a
rest (the beat slot still advances), or Degree for octave/
chromatic detail. Resolved against key + scale at placement.
Durations default to a full beat (each note holds its slot).
468 @classmethod 469 def notes ( 470 cls, 471 notes: typing.List[typing.Union[int, None]], 472 beats: typing.Optional[typing.List[float]] = None, 473 velocities: typing.Any = _DEFAULT_VELOCITY, 474 durations: typing.Any = 1.0, 475 probabilities: typing.Any = 1.0, 476 length: typing.Optional[float] = None, 477 ) -> "Motif": 478 479 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 480 481 for element in notes: 482 if not (isinstance(element, int) or element is None): 483 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 484 485 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
A melody written as absolute MIDI note numbers (60 = middle C); None = rest.
487 @classmethod 488 def hits ( 489 cls, 490 pitch: typing.Union[int, str], 491 beats: typing.List[float], 492 length: typing.Optional[float] = None, 493 velocities: typing.Any = _DEFAULT_VELOCITY, 494 durations: typing.Any = 0.1, 495 probabilities: typing.Any = 1.0, 496 ) -> "Motif": 497 498 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 499 500 return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length)
One pitch (usually a drum name) at a list of beat positions — the hit() convention.
502 @classmethod 503 def steps ( 504 cls, 505 steps: typing.List[int], 506 pitches: typing.Any, 507 velocities: typing.Any = _DEFAULT_VELOCITY, 508 durations: typing.Any = 0.1, 509 probabilities: typing.Any = 1.0, 510 step_duration: float = 0.25, 511 length: typing.Optional[float] = None, 512 ) -> "Motif": 513 514 """ 515 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 516 grid indices (sixteenths by default), ``pitches`` a scalar or 517 parallel list of MIDI ints or drum names. 518 """ 519 520 n = len(steps) 521 pitch_list = _expand("pitches", pitches, n) 522 onsets = [s * step_duration for s in steps] 523 524 if length is None and n: 525 length = float(math.ceil((max(steps) + 1) * step_duration)) 526 527 return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length)
Grid placement — the sequence() convention: steps are 0-based
grid indices (sixteenths by default), pitches a scalar or
parallel list of MIDI ints or drum names.
529 @classmethod 530 def euclidean ( 531 cls, 532 pulses: int, 533 steps: int, 534 pitch: typing.Union[int, str], 535 length: float = 4.0, 536 velocities: typing.Any = _DEFAULT_VELOCITY, 537 durations: typing.Any = 0.1, 538 probabilities: typing.Any = 1.0, 539 ) -> "Motif": 540 541 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 542 543 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 544 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 545 step_duration = length / steps 546 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 547 548 return cls._from_sequence( 549 [pitch] * len(onsets), 550 onsets, 551 velocities, durations, probabilities, length, 552 )
A euclidean rhythm as a value: pulses spread evenly across steps over length beats.
625 @classmethod 626 def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 627 628 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 629 630 return cls._control_writes(CC(control), list(values), list(beats), length, probabilities)
Discrete CC writes at beat positions — mirrors p.cc(); names resolve at placement.
632 @classmethod 633 def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 634 635 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 636 637 return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)
A CC value swept start → end over a beat range — mirrors p.cc_ramp().
639 @classmethod 640 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 641 642 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 643 644 return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities)
Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors p.pitch_bend().
646 @classmethod 647 def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 648 649 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 650 651 return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)
Pitch bend swept start → end (-1.0 to 1.0) over a beat range — mirrors p.pitch_bend_ramp().
653 @classmethod 654 def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 655 656 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 657 658 return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
Discrete NRPN parameter writes at beat positions — mirrors p.nrpn().
660 @classmethod 661 def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 662 663 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 664 665 return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
An NRPN value swept over a beat range — mirrors p.nrpn_ramp().
667 @classmethod 668 def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 669 670 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 671 672 return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
Discrete RPN parameter writes at beat positions — mirrors p.rpn().
674 @classmethod 675 def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 676 677 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 678 679 return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
An RPN value swept over a beat range — mirrors p.rpn_ramp().
681 @classmethod 682 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 683 684 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 685 686 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
Discrete OSC float sends at beat positions — mirrors p.osc().
688 @classmethod 689 def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif": 690 691 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 692 693 return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability)
An OSC float swept over a beat range — mirrors p.osc_ramp().
697 def then (self, other: "Motif") -> "Motif": 698 699 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 700 701 if not isinstance(other, Motif): 702 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 703 704 return Motif( 705 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 706 length = self.length + other.length, 707 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 708 )
Closed sequential concat: glue other after this motif into ONE longer motif.
710 @classmethod 711 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 712 713 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 714 715 result = cls.empty() 716 717 for m in motifs: 718 result = result.then(m) 719 720 return result
Fold a list of motifs into one with then (empty list → Motif.empty()).
722 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 723 724 """ 725 Parallel merge (the spelled form of ``&``): event union, length = max. 726 727 No implicit tiling — a short gesture stacked under a long figure 728 plays once. Phrase operands flatten first. 729 """ 730 731 if isinstance(other, Phrase): 732 merged = other.flatten() 733 elif isinstance(other, Motif): 734 merged = other 735 else: 736 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 737 738 return Motif( 739 events = self.events + merged.events, 740 length = max(self.length, merged.length), 741 controls = self.controls + merged.controls, 742 )
Parallel merge (the spelled form of &): event union, length = max.
No implicit tiling — a short gesture stacked under a long figure plays once. Phrase operands flatten first.
744 def slice (self, start: float, end: float) -> "Motif": 745 746 """ 747 A window onto the motif, on its own authority: events starting outside 748 are dropped; durations and ramp spans truncate at the cut (a truncated 749 ramp ends at its interpolated cut value). Beats shift so the window 750 starts at 0. 751 """ 752 753 if end <= start: 754 raise ValueError(f"slice end ({end}) must be after start ({start})") 755 756 events = tuple( 757 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 758 for e in self.events 759 if start <= e.beat < end 760 ) 761 762 controls = [] 763 764 for c in self.controls: 765 if not (start <= c.beat < end): 766 continue 767 if c.end is not None and c.beat + c.span > end: 768 kept = end - c.beat 769 controls.append(dataclasses.replace( 770 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 771 )) 772 else: 773 controls.append(dataclasses.replace(c, beat=c.beat - start)) 774 775 return Motif(events=events, length=end - start, controls=tuple(controls))
A window onto the motif, on its own authority: events starting outside are dropped; durations and ramp spans truncate at the cut (a truncated ramp ends at its interpolated cut value). Beats shift so the window starts at 0.
814 def reverse (self) -> "Motif": 815 816 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 817 818 events = tuple( 819 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 820 for e in self.events 821 ) 822 controls = tuple( 823 dataclasses.replace( 824 c, 825 beat = max(0.0, self.length - c.beat - c.span), 826 start = c.start if c.end is None else c.end, 827 end = c.end if c.end is None else c.start, 828 ) 829 for c in self.controls 830 ) 831 832 return Motif(events=events, length=self.length, controls=controls)
Mirror the figure in time; ramps swap direction (a rising sweep falls).
834 def rotate (self, beats: float) -> "Motif": 835 836 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 837 838 if self.length == 0: 839 return self 840 841 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 842 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 843 844 return Motif(events=events, length=self.length, controls=controls)
Shift every onset by beats, wrapping modulo the length (spans ride along).
846 def stretch (self, factor: float) -> "Motif": 847 848 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 849 850 if factor <= 0: 851 raise ValueError(f"Stretch factor must be positive — got {factor}") 852 853 events = tuple( 854 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 855 for e in self.events 856 ) 857 controls = tuple( 858 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 859 for c in self.controls 860 ) 861 862 return Motif(events=events, length=self.length * factor, controls=controls)
Scale time by factor (2.0 = half-time feel): beats, durations, spans, and length.
864 def quantize (self, grid: float) -> "Motif": 865 866 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched).""" 867 868 if grid <= 0: 869 raise ValueError(f"Quantize grid must be positive — got {grid}") 870 871 events = tuple( 872 dataclasses.replace(e, beat=round(e.beat / grid) * grid) 873 for e in self.events 874 ) 875 876 return Motif(events=events, length=self.length, controls=self.controls)
Snap note onsets to the nearest multiple of grid beats (control gestures untouched).
878 def accent (self, beat: float, amount: int = 20) -> "Motif": 879 880 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 881 882 def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]: 883 if isinstance(velocity, tuple): 884 return (min(127, velocity[0] + amount), min(127, velocity[1] + amount)) 885 return min(127, velocity + amount) 886 887 events = tuple( 888 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 889 for e in self.events 890 ) 891 892 return Motif(events=events, length=self.length, controls=self.controls)
Add amount velocity to every note at the given beat position (0-based beats).
894 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif": 895 896 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 897 898 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 899 900 return Motif(events=events, length=self.length, controls=self.controls)
Replace every note's velocity (an int, or a (low, high) random range).
930 def vary ( 931 self, 932 notes: int = 1, 933 position: str = "end", 934 seed: typing.Optional[int] = None, 935 rng: typing.Optional[random.Random] = None, 936 ) -> "Motif": 937 938 """Replace a few pitches, preserving the rhythm — the smallest variation. 939 940 Rhythm, velocities, durations, rests, and control gestures are 941 untouched; only the chosen notes' pitches move (by a small melodic 942 nudge: scale steps for degrees, semitones for MIDI ints). 943 944 Parameters: 945 notes: How many pitched notes to vary (clamped to what exists). 946 position: Which notes — ``"end"`` (the tail, the default), 947 ``"start"``, or ``"anywhere"`` (drawn from the stream). 948 seed: Seed for the variation. A standalone vary without a seed 949 warns — module-level nondeterminism breaks live reload. 950 rng: An explicit random stream (overrides ``seed``; used by 951 recipe machinery). 952 953 Example: 954 ```python 955 answer = call.vary(notes=1, seed=4) # same figure, new tail note 956 ``` 957 """ 958 959 if notes < 0: 960 raise ValueError(f"notes must be at least 0, got {notes}") 961 if position not in ("end", "start", "anywhere"): 962 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 963 964 if rng is None: 965 if seed is None: 966 warnings.warn( 967 "vary() without seed= is nondeterministic — pass seed= so the " 968 "value survives live reload", 969 stacklevel = 2, 970 ) 971 rng = random.Random() 972 else: 973 rng = random.Random(seed) 974 975 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 976 count = min(notes, len(pitched_indices)) 977 978 if count == 0: 979 return self 980 981 if position == "end": 982 chosen = pitched_indices[-count:] 983 elif position == "start": 984 chosen = pitched_indices[:count] 985 else: 986 chosen = sorted(rng.sample(pitched_indices, count)) 987 988 events = list(self.events) 989 990 for index in chosen: 991 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng)) 992 993 return Motif(events = tuple(events), length = self.length, controls = self.controls)
Replace a few pitches, preserving the rhythm — the smallest variation.
Rhythm, velocities, durations, rests, and control gestures are untouched; only the chosen notes' pitches move (by a small melodic nudge: scale steps for degrees, semitones for MIDI ints).
Arguments:
- notes: How many pitched notes to vary (clamped to what exists).
- position: Which notes —
"end"(the tail, the default),"start", or"anywhere"(drawn from the stream). - seed: Seed for the variation. A standalone vary without a seed warns — module-level nondeterminism breaks live reload.
- rng: An explicit random stream (overrides
seed; used by recipe machinery).
Example:
answer = call.vary(notes=1, seed=4) # same figure, new tail note
995 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 996 997 """Call → response: re-aim the tail to a stable degree. 998 999 The classic consequent move — the figure repeats but its last pitched 1000 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1001 or a full ``Degree`` for register control). Everything else — 1002 rhythm, the other pitches, velocities, controls — is untouched. 1003 1004 Degree content only: absolute MIDI has no degrees to re-aim (build 1005 the call with ``motif([...])``), and drums raise. 1006 """ 1007 1008 target = to if isinstance(to, Degree) else Degree(int(to)) 1009 1010 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1011 1012 if not pitched_indices: 1013 return self 1014 1015 last = self.events[pitched_indices[-1]] 1016 1017 if not isinstance(last.pitch, Degree): 1018 raise TypeError( 1019 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1020 "content (build the call with motif([...]) for degree content)" 1021 ) 1022 1023 if isinstance(to, int): 1024 # Keep the call's register: only the step is re-aimed. 1025 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1026 1027 events = list(self.events) 1028 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1029 1030 return Motif(events = tuple(events), length = self.length, controls = self.controls)
Call → response: re-aim the tail to a stable degree.
The classic consequent move — the figure repeats but its last pitched
note lands home (degree 1 by default; pass to=5 for a half-close,
or a full Degree for register control). Everything else —
rhythm, the other pitches, velocities, controls — is untouched.
Degree content only: absolute MIDI has no degrees to re-aim (build
the call with motif([...])), and drums raise.
1032 def pitched (self, spec: PitchSpec) -> "Motif": 1033 1034 """ 1035 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1036 1037 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1038 tones; any other string is a drum name; ints are MIDI; Degree / 1039 ChordTone / Approach pass through. 1040 """ 1041 1042 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1043 spec = ChordTone(spec) 1044 1045 events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) 1046 1047 return Motif(events=events, length=self.length, controls=self.controls)
Replace every pitch with one spec — a kick rhythm becomes a bass line.
"root" / "third" / "fifth" / "seventh" become chord
tones; any other string is a drum name; ints are MIDI; Degree /
ChordTone / Approach pass through.
1049 def rhythm (self) -> "Motif": 1050 1051 """ 1052 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1053 1054 Timing, velocities, durations, and probabilities survive; re-pitch 1055 with :meth:`pitched` before placement (placing a skeleton raises). 1056 """ 1057 1058 events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) 1059 1060 return Motif(events=events, length=self.length)
Strip pitches (and control gestures): a reusable rhythmic skeleton.
Timing, velocities, durations, and probabilities survive; re-pitch
with pitched() before placement (placing a skeleton raises).
1062 def onsets (self) -> typing.List[float]: 1063 1064 """The note onset beats, in order — ready for rhythm-first generation.""" 1065 1066 return [e.beat for e in self.events]
The note onset beats, in order — ready for rhythm-first generation.
1068 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1069 1070 """ 1071 Transpose pitched content; the keyword names the unit. 1072 1073 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1074 raises on absolute-MIDI or drum content; ``semitones=`` is the 1075 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1076 on both — a transposed drum name is a different instrument, not a 1077 transposition. 1078 """ 1079 1080 if (steps is None) == (semitones is None): 1081 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1082 1083 def move (pitch: PitchSpec) -> PitchSpec: 1084 1085 if pitch is None: 1086 return None 1087 1088 if isinstance(pitch, Approach): 1089 moved = move(pitch.target) 1090 if not isinstance(moved, (int, Degree, ChordTone)): 1091 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1092 return Approach(moved) 1093 1094 if steps is not None: 1095 if isinstance(pitch, Degree): 1096 return dataclasses.replace(pitch, step=pitch.step + steps) 1097 raise TypeError( 1098 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1099 f"has no degrees (use semitones= for MIDI ints)" 1100 ) 1101 1102 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1103 1104 if isinstance(pitch, int): 1105 return pitch + semitones 1106 if isinstance(pitch, Degree): 1107 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1108 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1109 1110 events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) 1111 1112 return Motif(events=events, length=self.length, controls=self.controls)
Transpose pitched content; the keyword names the unit.
steps= moves scale degrees diatonically (the sequencing move) and
raises on absolute-MIDI or drum content; semitones= is the
literal chromatic form for MIDI ints and degrees. Drum motifs raise
on both — a transposed drum name is a different instrument, not a
transposition.
1114 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1115 1116 """ 1117 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1118 degree content around a degree pivot (default: the first note's pitch). 1119 Drum motifs raise. 1120 """ 1121 1122 pitched_events = [e for e in self.events if e.pitch is not None] 1123 1124 if not pitched_events: 1125 return self 1126 1127 first = pitched_events[0].pitch 1128 1129 if pivot is None: 1130 if isinstance(first, int): 1131 pivot = first 1132 elif isinstance(first, Degree): 1133 pivot = first.step 1134 else: 1135 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1136 1137 def mirror (pitch: PitchSpec) -> PitchSpec: 1138 1139 if pitch is None: 1140 return None 1141 if isinstance(pitch, int): 1142 return 2 * pivot - pitch 1143 if isinstance(pitch, Degree): 1144 mirrored = 2 * pivot - pitch.step 1145 if mirrored < 1: 1146 raise ValueError( 1147 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1148 f"raise the pivot or use Degree octaves" 1149 ) 1150 return dataclasses.replace(pitch, step=mirrored, chroma=-pitch.chroma) 1151 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1152 1153 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events) 1154 1155 return Motif(events=events, length=self.length, controls=self.controls)
Mirror pitches around a pivot: MIDI content around a MIDI pivot, degree content around a degree pivot (default: the first note's pitch). Drum motifs raise.
1159 def describe (self) -> str: 1160 1161 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1162 1163 notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) 1164 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1165 1166 if self.controls: 1167 gestures = ", ".join(_control_label(c) for c in self.controls) 1168 parts.append(f"controls [{gestures}]") 1169 1170 return " ".join(parts)
A readable one-line summary: length, notes (pitch@beat), and control gestures.
1275@dataclasses.dataclass(frozen=True) 1276class Phrase: 1277 1278 """ 1279 A sequence of Motifs with segmentation preserved. 1280 1281 Segmentation is the unit of editing — it is what development and 1282 per-region regeneration operate on. ``flatten()`` erases it into one 1283 long Motif. Length is the sum of segment lengths. 1284 1285 A phrase made by :meth:`develop` carries its recipe, so 1286 :meth:`reroll` can regenerate a region; transforms and hand edits 1287 return recipe-less phrases (their notes no longer come from the 1288 recipe, so there is nothing honest to regenerate from). 1289 """ 1290 1291 segments: typing.Tuple[Motif, ...] 1292 recipe: typing.Optional[_PhraseRecipe] 1293 1294 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1295 1296 """Coerce any iterable of Motifs.""" 1297 1298 segments = tuple(segments) 1299 1300 for segment in segments: 1301 if not isinstance(segment, Motif): 1302 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1303 1304 object.__setattr__(self, "segments", segments) 1305 object.__setattr__(self, "recipe", recipe) 1306 1307 @property 1308 def length (self) -> float: 1309 1310 """Total length in beats (sum of segment lengths).""" 1311 1312 return sum(segment.length for segment in self.segments) 1313 1314 @classmethod 1315 def develop ( 1316 cls, 1317 motif: Motif, 1318 bars: int = 8, 1319 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1320 seed: typing.Optional[int] = None, 1321 beats_per_bar: float = 4.0, 1322 ) -> "Phrase": 1323 1324 """Grow a motif into a phrase by a plan — the phrase generator. 1325 1326 ``plan`` follows the standard form. The literal form is a **list of 1327 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1328 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1329 label is a generated contrast unit (the source's rhythm, freshly 1330 re-pitched), a repeated label is a restatement, and *bars* spreads 1331 evenly across the units. A bare string is a **recipe name** from 1332 the curated table — ``plan="call_response"`` (call, answer, call, 1333 varied answer) — reserved for plans whose semantics exceed a label 1334 skeleton. A letter string is not a plan: a sequence of labels is a 1335 sequence, so it is a list. 1336 1337 The result carries its recipe, so :meth:`reroll` can regenerate a 1338 region later. 1339 1340 Parameters: 1341 motif: The source unit (its length must be ``bars / len(units)`` 1342 bars — the plan's units tile the phrase exactly). 1343 bars: Phrase length in bars (must divide evenly by the unit 1344 count). 1345 plan: A list of unit labels, or a recipe name. 1346 seed: Seed for the generated units. Without one, develop() 1347 warns — module-level nondeterminism breaks live reload. 1348 beats_per_bar: Bar size in beats (the value is context-free; 1349 4 is the common-time default). 1350 1351 Example: 1352 ```python 1353 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1354 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1355 ``` 1356 """ 1357 1358 if plan is None: 1359 raise ValueError( 1360 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1361 'or a recipe name (plan="call_response")' 1362 ) 1363 1364 if seed is None: 1365 warnings.warn( 1366 "develop() without seed= is nondeterministic — pass seed= so the " 1367 "value survives live reload", 1368 stacklevel = 2, 1369 ) 1370 1371 if isinstance(plan, str): 1372 1373 if plan not in _PHRASE_RECIPES: 1374 known = ", ".join(sorted(_PHRASE_RECIPES)) 1375 hint = "" 1376 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1377 spelled = ", ".join(repr(c) for c in plan) 1378 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1379 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1380 1381 units = _PHRASE_RECIPES[plan](motif, seed) 1382 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1383 1384 else: 1385 1386 labels = list(plan) 1387 1388 if not labels or not all(isinstance(label, str) and label for label in labels): 1389 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1390 1391 generated: typing.Dict[str, Motif] = {labels[0]: motif} 1392 1393 for label in labels: 1394 if label not in generated: 1395 generated[label] = _contrast_unit(motif, random.Random(f"{seed}:unit:{label}")) 1396 1397 units = [generated[label] for label in labels] 1398 stored_plan = tuple(labels) 1399 1400 if bars % len(units) != 0: 1401 raise ValueError( 1402 f"bars={bars} does not divide evenly across {len(units)} plan units — " 1403 "each unit must fill a whole number of bars" 1404 ) 1405 1406 unit_beats = bars * beats_per_bar / len(units) 1407 1408 if abs(motif.length - unit_beats) > 1e-9: 1409 raise ValueError( 1410 f"the motif is {motif.length:g} beats but each of the {len(units)} plan units " 1411 f"spans {unit_beats:g} beats ({bars} bars / {len(units)} units) — " 1412 "adjust bars, the plan, or the motif's length" 1413 ) 1414 1415 return cls(units, recipe = _PhraseRecipe( 1416 source = motif, 1417 plan = stored_plan, 1418 bars = bars, 1419 seed = seed, 1420 beats_per_bar = beats_per_bar, 1421 )) 1422 1423 def reroll ( 1424 self, 1425 bar: typing.Optional[int] = None, 1426 bars: typing.Optional[typing.Sequence[int]] = None, 1427 seed: typing.Optional[int] = None, 1428 ) -> "Phrase": 1429 1430 """Regenerate only the named bars — rhythm and boundary pitches kept. 1431 1432 Within each named bar, the first and last pitched notes stay (the 1433 boundary pins) and the interior pitches re-roll from the recipe's 1434 stream; onsets, durations, velocities, rests, drums, and control 1435 gestures are untouched. Segmentation and the recipe survive, so 1436 rerolls compose. 1437 1438 Only a phrase that carries a recipe can reroll — a hand-written or 1439 transformed phrase raises loudly (its notes no longer come from a 1440 generator, so regenerating them would invent music). 1441 1442 Parameters: 1443 bar: A single 1-based bar to reroll. 1444 bars: A list of 1-based bars (the paired plural spelling). 1445 seed: Seed for the new pitches (salted per bar). Without one, 1446 reroll() warns. 1447 1448 Example: 1449 ```python 1450 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 1451 ``` 1452 """ 1453 1454 if self.recipe is None: 1455 raise ValueError( 1456 "this phrase carries no recipe (it was written by hand, or transformed " 1457 "since generation) — reroll() regenerates from a recipe; edit segments " 1458 "with replace(), or rebuild with Phrase.develop()" 1459 ) 1460 1461 if (bar is None) == (bars is None): 1462 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 1463 1464 region = [bar] if bar is not None else list(bars or []) 1465 beats_per_bar = self.recipe.beats_per_bar 1466 total_bars = int(round(self.length / beats_per_bar)) 1467 1468 for number in region: 1469 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 1470 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 1471 1472 if seed is None: 1473 warnings.warn( 1474 "reroll() without seed= is nondeterministic — pass seed= so the " 1475 "value survives live reload", 1476 stacklevel = 2, 1477 ) 1478 1479 windows = [ 1480 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{seed}:reroll:{number}")) 1481 for number in sorted(set(region)) 1482 ] 1483 1484 new_segments: typing.List[Motif] = [] 1485 offset = 0.0 1486 1487 for segment in self.segments: 1488 1489 events = list(segment.events) 1490 1491 for window_start, window_end, rng in windows: 1492 1493 inside = [ 1494 index for index, event in enumerate(events) 1495 if window_start <= offset + event.beat < window_end 1496 and event.pitch is not None and not isinstance(event.pitch, str) 1497 ] 1498 1499 # Boundary pins: the first and last pitched notes of the bar 1500 # stay; only the interior re-rolls. 1501 for index in inside[1:-1]: 1502 events[index] = dataclasses.replace( 1503 events[index], 1504 pitch = segment._nudged_pitch(events[index].pitch, rng), 1505 ) 1506 1507 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 1508 offset += segment.length 1509 1510 return Phrase(new_segments, recipe = self.recipe) 1511 1512 def flatten (self) -> Motif: 1513 1514 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 1515 1516 return Motif.join(self.segments) 1517 1518 # ── algebra ───────────────────────────────────────────────────────── 1519 1520 def __add__ (self, other: typing.Any) -> "Phrase": 1521 1522 """Append a Motif segment, or concatenate another Phrase's segments.""" 1523 1524 if isinstance(other, Motif): 1525 return Phrase(self.segments + (other,)) 1526 if isinstance(other, Phrase): 1527 return Phrase(self.segments + other.segments) 1528 1529 return NotImplemented 1530 1531 def __radd__ (self, other: typing.Any) -> "Phrase": 1532 1533 """A Motif on the left prepends as a segment.""" 1534 1535 if isinstance(other, Motif): 1536 return Phrase((other,) + self.segments) 1537 1538 return NotImplemented 1539 1540 def __mul__ (self, count: int) -> "Phrase": 1541 1542 """Tile the segments *count* times.""" 1543 1544 if not isinstance(count, int): 1545 return NotImplemented 1546 if count < 0: 1547 raise ValueError(f"Repetition count must be non-negative — got {count}") 1548 1549 return Phrase(self.segments * count) 1550 1551 __rmul__ = __mul__ 1552 1553 def __and__ (self, other: typing.Any) -> Motif: 1554 1555 """Parallel merge is vertical: Phrase operands flatten to Motif first.""" 1556 1557 if isinstance(other, (Motif, Phrase)): 1558 return self.flatten().stack(other) 1559 1560 return NotImplemented 1561 1562 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 1563 1564 """The spelled form of ``&`` — flattens, then merges.""" 1565 1566 return self.flatten().stack(other) 1567 1568 def slice (self, start: float, end: float) -> "Phrase": 1569 1570 """A window; re-segments at the cut points (partial segments are sliced).""" 1571 1572 segments = [] 1573 offset = 0.0 1574 1575 for segment in self.segments: 1576 seg_start, seg_end = offset, offset + segment.length 1577 lo, hi = max(start, seg_start), min(end, seg_end) 1578 if lo < hi: 1579 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 1580 offset = seg_end 1581 1582 return Phrase(segments) 1583 1584 def replace (self, position: int, motif: Motif) -> "Phrase": 1585 1586 """Replace the segment at a 1-based position (musicians count from one).""" 1587 1588 if not 1 <= position <= len(self.segments): 1589 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 1590 1591 segments = list(self.segments) 1592 segments[position - 1] = motif 1593 1594 return Phrase(segments) 1595 1596 # ── transforms: lifted segment-wise, except time-reordering ───────── 1597 1598 def reverse (self) -> "Phrase": 1599 1600 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 1601 1602 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments))) 1603 1604 def rotate (self, beats: float) -> "Phrase": 1605 1606 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 1607 1608 flat = self.flatten().rotate(beats) 1609 segments = [] 1610 offset = 0.0 1611 1612 # Re-segment by onset (events keep their full durations — a note may 1613 # ring past its new segment, exactly as it does on the flat timeline). 1614 for segment in self.segments: 1615 lo, hi = offset, offset + segment.length 1616 segments.append(Motif( 1617 events = tuple( 1618 dataclasses.replace(e, beat=e.beat - lo) 1619 for e in flat.events if lo <= e.beat < hi 1620 ), 1621 length = segment.length, 1622 controls = tuple( 1623 dataclasses.replace(c, beat=c.beat - lo) 1624 for c in flat.controls if lo <= c.beat < hi 1625 ), 1626 )) 1627 offset = hi 1628 1629 return Phrase(segments) 1630 1631 def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase": 1632 1633 """Apply a Motif transform to every segment.""" 1634 1635 return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments)) 1636 1637 def stretch (self, factor: float) -> "Phrase": 1638 1639 """Scale time in every segment (lengths scale with them).""" 1640 1641 return self._lift("stretch", factor) 1642 1643 def quantize (self, grid: float) -> "Phrase": 1644 1645 """Snap note onsets segment-wise.""" 1646 1647 return self._lift("quantize", grid) 1648 1649 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase": 1650 1651 """Replace every note's velocity, segment-wise.""" 1652 1653 return self._lift("with_velocity", velocity) 1654 1655 def pitched (self, spec: PitchSpec) -> "Phrase": 1656 1657 """Replace every pitch, segment-wise.""" 1658 1659 return self._lift("pitched", spec) 1660 1661 def rhythm (self) -> "Phrase": 1662 1663 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 1664 1665 return self._lift("rhythm") 1666 1667 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 1668 1669 """Transpose every segment (see :meth:`Motif.transpose`).""" 1670 1671 return self._lift("transpose", steps=steps, semitones=semitones) 1672 1673 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 1674 1675 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 1676 1677 if pivot is None: 1678 for segment in self.segments: 1679 for event in segment.events: 1680 if event.pitch is not None: 1681 if isinstance(event.pitch, int): 1682 pivot = event.pitch 1683 elif isinstance(event.pitch, Degree): 1684 pivot = event.pitch.step 1685 break 1686 if pivot is not None: 1687 break 1688 1689 return self._lift("invert", pivot=pivot) 1690 1691 def describe (self) -> str: 1692 1693 """A readable summary: total length and each segment on its own line.""" 1694 1695 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 1696 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 1697 1698 return "\n".join([header] + lines) 1699 1700 def __str__ (self) -> str: 1701 1702 """Printable form (same as :meth:`describe`).""" 1703 1704 return self.describe()
A sequence of Motifs with segmentation preserved.
Segmentation is the unit of editing — it is what development and
per-region regeneration operate on. flatten() erases it into one
long Motif. Length is the sum of segment lengths.
A phrase made by develop() carries its recipe, so
reroll() can regenerate a region; transforms and hand edits
return recipe-less phrases (their notes no longer come from the
recipe, so there is nothing honest to regenerate from).
1294 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1295 1296 """Coerce any iterable of Motifs.""" 1297 1298 segments = tuple(segments) 1299 1300 for segment in segments: 1301 if not isinstance(segment, Motif): 1302 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1303 1304 object.__setattr__(self, "segments", segments) 1305 object.__setattr__(self, "recipe", recipe)
Coerce any iterable of Motifs.
1307 @property 1308 def length (self) -> float: 1309 1310 """Total length in beats (sum of segment lengths).""" 1311 1312 return sum(segment.length for segment in self.segments)
Total length in beats (sum of segment lengths).
1314 @classmethod 1315 def develop ( 1316 cls, 1317 motif: Motif, 1318 bars: int = 8, 1319 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1320 seed: typing.Optional[int] = None, 1321 beats_per_bar: float = 4.0, 1322 ) -> "Phrase": 1323 1324 """Grow a motif into a phrase by a plan — the phrase generator. 1325 1326 ``plan`` follows the standard form. The literal form is a **list of 1327 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1328 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1329 label is a generated contrast unit (the source's rhythm, freshly 1330 re-pitched), a repeated label is a restatement, and *bars* spreads 1331 evenly across the units. A bare string is a **recipe name** from 1332 the curated table — ``plan="call_response"`` (call, answer, call, 1333 varied answer) — reserved for plans whose semantics exceed a label 1334 skeleton. A letter string is not a plan: a sequence of labels is a 1335 sequence, so it is a list. 1336 1337 The result carries its recipe, so :meth:`reroll` can regenerate a 1338 region later. 1339 1340 Parameters: 1341 motif: The source unit (its length must be ``bars / len(units)`` 1342 bars — the plan's units tile the phrase exactly). 1343 bars: Phrase length in bars (must divide evenly by the unit 1344 count). 1345 plan: A list of unit labels, or a recipe name. 1346 seed: Seed for the generated units. Without one, develop() 1347 warns — module-level nondeterminism breaks live reload. 1348 beats_per_bar: Bar size in beats (the value is context-free; 1349 4 is the common-time default). 1350 1351 Example: 1352 ```python 1353 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1354 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1355 ``` 1356 """ 1357 1358 if plan is None: 1359 raise ValueError( 1360 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1361 'or a recipe name (plan="call_response")' 1362 ) 1363 1364 if seed is None: 1365 warnings.warn( 1366 "develop() without seed= is nondeterministic — pass seed= so the " 1367 "value survives live reload", 1368 stacklevel = 2, 1369 ) 1370 1371 if isinstance(plan, str): 1372 1373 if plan not in _PHRASE_RECIPES: 1374 known = ", ".join(sorted(_PHRASE_RECIPES)) 1375 hint = "" 1376 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1377 spelled = ", ".join(repr(c) for c in plan) 1378 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1379 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1380 1381 units = _PHRASE_RECIPES[plan](motif, seed) 1382 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1383 1384 else: 1385 1386 labels = list(plan) 1387 1388 if not labels or not all(isinstance(label, str) and label for label in labels): 1389 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1390 1391 generated: typing.Dict[str, Motif] = {labels[0]: motif} 1392 1393 for label in labels: 1394 if label not in generated: 1395 generated[label] = _contrast_unit(motif, random.Random(f"{seed}:unit:{label}")) 1396 1397 units = [generated[label] for label in labels] 1398 stored_plan = tuple(labels) 1399 1400 if bars % len(units) != 0: 1401 raise ValueError( 1402 f"bars={bars} does not divide evenly across {len(units)} plan units — " 1403 "each unit must fill a whole number of bars" 1404 ) 1405 1406 unit_beats = bars * beats_per_bar / len(units) 1407 1408 if abs(motif.length - unit_beats) > 1e-9: 1409 raise ValueError( 1410 f"the motif is {motif.length:g} beats but each of the {len(units)} plan units " 1411 f"spans {unit_beats:g} beats ({bars} bars / {len(units)} units) — " 1412 "adjust bars, the plan, or the motif's length" 1413 ) 1414 1415 return cls(units, recipe = _PhraseRecipe( 1416 source = motif, 1417 plan = stored_plan, 1418 bars = bars, 1419 seed = seed, 1420 beats_per_bar = beats_per_bar, 1421 ))
Grow a motif into a phrase by a plan — the phrase generator.
plan follows the standard form. The literal form is a list of
unit 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)
1423 def reroll ( 1424 self, 1425 bar: typing.Optional[int] = None, 1426 bars: typing.Optional[typing.Sequence[int]] = None, 1427 seed: typing.Optional[int] = None, 1428 ) -> "Phrase": 1429 1430 """Regenerate only the named bars — rhythm and boundary pitches kept. 1431 1432 Within each named bar, the first and last pitched notes stay (the 1433 boundary pins) and the interior pitches re-roll from the recipe's 1434 stream; onsets, durations, velocities, rests, drums, and control 1435 gestures are untouched. Segmentation and the recipe survive, so 1436 rerolls compose. 1437 1438 Only a phrase that carries a recipe can reroll — a hand-written or 1439 transformed phrase raises loudly (its notes no longer come from a 1440 generator, so regenerating them would invent music). 1441 1442 Parameters: 1443 bar: A single 1-based bar to reroll. 1444 bars: A list of 1-based bars (the paired plural spelling). 1445 seed: Seed for the new pitches (salted per bar). Without one, 1446 reroll() warns. 1447 1448 Example: 1449 ```python 1450 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 1451 ``` 1452 """ 1453 1454 if self.recipe is None: 1455 raise ValueError( 1456 "this phrase carries no recipe (it was written by hand, or transformed " 1457 "since generation) — reroll() regenerates from a recipe; edit segments " 1458 "with replace(), or rebuild with Phrase.develop()" 1459 ) 1460 1461 if (bar is None) == (bars is None): 1462 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 1463 1464 region = [bar] if bar is not None else list(bars or []) 1465 beats_per_bar = self.recipe.beats_per_bar 1466 total_bars = int(round(self.length / beats_per_bar)) 1467 1468 for number in region: 1469 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 1470 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 1471 1472 if seed is None: 1473 warnings.warn( 1474 "reroll() without seed= is nondeterministic — pass seed= so the " 1475 "value survives live reload", 1476 stacklevel = 2, 1477 ) 1478 1479 windows = [ 1480 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{seed}:reroll:{number}")) 1481 for number in sorted(set(region)) 1482 ] 1483 1484 new_segments: typing.List[Motif] = [] 1485 offset = 0.0 1486 1487 for segment in self.segments: 1488 1489 events = list(segment.events) 1490 1491 for window_start, window_end, rng in windows: 1492 1493 inside = [ 1494 index for index, event in enumerate(events) 1495 if window_start <= offset + event.beat < window_end 1496 and event.pitch is not None and not isinstance(event.pitch, str) 1497 ] 1498 1499 # Boundary pins: the first and last pitched notes of the bar 1500 # stay; only the interior re-rolls. 1501 for index in inside[1:-1]: 1502 events[index] = dataclasses.replace( 1503 events[index], 1504 pitch = segment._nudged_pitch(events[index].pitch, rng), 1505 ) 1506 1507 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 1508 offset += segment.length 1509 1510 return Phrase(new_segments, recipe = self.recipe)
Regenerate only the named bars — rhythm and boundary pitches kept.
Within each named bar, the first and last pitched notes stay (the boundary pins) and the interior pitches re-roll from the recipe's stream; onsets, durations, velocities, rests, drums, and control gestures are untouched. Segmentation and the recipe survive, so rerolls compose.
Only a phrase that carries a recipe can reroll — a hand-written or transformed phrase raises loudly (its notes no longer come from a generator, so regenerating them would invent music).
Arguments:
- bar: A single 1-based bar to reroll.
- bars: A list of 1-based bars (the paired plural spelling).
- seed: Seed for the new pitches (salted per bar). Without one, reroll() warns.
Example:
lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept
1512 def flatten (self) -> Motif: 1513 1514 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 1515 1516 return Motif.join(self.segments)
Erase segmentation: one long Motif (the monoid homomorphism onto then).
1562 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 1563 1564 """The spelled form of ``&`` — flattens, then merges.""" 1565 1566 return self.flatten().stack(other)
The spelled form of & — flattens, then merges.
1568 def slice (self, start: float, end: float) -> "Phrase": 1569 1570 """A window; re-segments at the cut points (partial segments are sliced).""" 1571 1572 segments = [] 1573 offset = 0.0 1574 1575 for segment in self.segments: 1576 seg_start, seg_end = offset, offset + segment.length 1577 lo, hi = max(start, seg_start), min(end, seg_end) 1578 if lo < hi: 1579 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 1580 offset = seg_end 1581 1582 return Phrase(segments)
A window; re-segments at the cut points (partial segments are sliced).
1584 def replace (self, position: int, motif: Motif) -> "Phrase": 1585 1586 """Replace the segment at a 1-based position (musicians count from one).""" 1587 1588 if not 1 <= position <= len(self.segments): 1589 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 1590 1591 segments = list(self.segments) 1592 segments[position - 1] = motif 1593 1594 return Phrase(segments)
Replace the segment at a 1-based position (musicians count from one).
1598 def reverse (self) -> "Phrase": 1599 1600 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 1601 1602 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
Reverse the whole timeline: segments reverse order AND each reverses internally.
1604 def rotate (self, beats: float) -> "Phrase": 1605 1606 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 1607 1608 flat = self.flatten().rotate(beats) 1609 segments = [] 1610 offset = 0.0 1611 1612 # Re-segment by onset (events keep their full durations — a note may 1613 # ring past its new segment, exactly as it does on the flat timeline). 1614 for segment in self.segments: 1615 lo, hi = offset, offset + segment.length 1616 segments.append(Motif( 1617 events = tuple( 1618 dataclasses.replace(e, beat=e.beat - lo) 1619 for e in flat.events if lo <= e.beat < hi 1620 ), 1621 length = segment.length, 1622 controls = tuple( 1623 dataclasses.replace(c, beat=c.beat - lo) 1624 for c in flat.controls if lo <= c.beat < hi 1625 ), 1626 )) 1627 offset = hi 1628 1629 return Phrase(segments)
Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.
1637 def stretch (self, factor: float) -> "Phrase": 1638 1639 """Scale time in every segment (lengths scale with them).""" 1640 1641 return self._lift("stretch", factor)
Scale time in every segment (lengths scale with them).
1643 def quantize (self, grid: float) -> "Phrase": 1644 1645 """Snap note onsets segment-wise.""" 1646 1647 return self._lift("quantize", grid)
Snap note onsets segment-wise.
1649 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase": 1650 1651 """Replace every note's velocity, segment-wise.""" 1652 1653 return self._lift("with_velocity", velocity)
Replace every note's velocity, segment-wise.
1655 def pitched (self, spec: PitchSpec) -> "Phrase": 1656 1657 """Replace every pitch, segment-wise.""" 1658 1659 return self._lift("pitched", spec)
Replace every pitch, segment-wise.
1661 def rhythm (self) -> "Phrase": 1662 1663 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 1664 1665 return self._lift("rhythm")
Strip pitches segment-wise: a phrase-shaped skeleton.
1667 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 1668 1669 """Transpose every segment (see :meth:`Motif.transpose`).""" 1670 1671 return self._lift("transpose", steps=steps, semitones=semitones)
Transpose every segment (see Motif.transpose()).
1673 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 1674 1675 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 1676 1677 if pivot is None: 1678 for segment in self.segments: 1679 for event in segment.events: 1680 if event.pitch is not None: 1681 if isinstance(event.pitch, int): 1682 pivot = event.pitch 1683 elif isinstance(event.pitch, Degree): 1684 pivot = event.pitch.step 1685 break 1686 if pivot is not None: 1687 break 1688 1689 return self._lift("invert", pivot=pivot)
Mirror pitches in every segment around one pivot (see Motif.invert()).
1691 def describe (self) -> str: 1692 1693 """A readable summary: total length and each segment on its own line.""" 1694 1695 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 1696 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 1697 1698 return "\n".join([header] + lines)
A readable summary: total length and each segment on its own line.
1707def motif ( 1708 degrees: typing.List[typing.Union[int, Degree, None]], 1709 beats: typing.Optional[typing.List[float]] = None, 1710 velocities: typing.Any = _DEFAULT_VELOCITY, 1711 durations: typing.Any = 1.0, 1712 probabilities: typing.Any = 1.0, 1713 length: typing.Optional[float] = None, 1714) -> Motif: 1715 1716 """ 1717 The lowercase shortcut: a melody as 1-based scale degrees. 1718 1719 ``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` — 1720 relative pitch is the primary form. For absolute MIDI note numbers use 1721 ``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so 1722 a pasted MIDI list fails loud instead of squealing octaves up. 1723 """ 1724 1725 return Motif.degrees( 1726 degrees, 1727 beats = beats, 1728 velocities = velocities, 1729 durations = durations, 1730 probabilities = probabilities, 1731 length = length, 1732 )
The lowercase shortcut: a melody as 1-based scale degrees.
subsequence.motif([5, 6, 5, 3]) is Motif.degrees([5, 6, 5, 3]) —
relative pitch is the primary form. For absolute MIDI note numbers use
Motif.notes([64, 65, 64, 60]); implausibly large ints here raise so
a pasted MIDI list fails loud instead of squealing octaves up.
60@dataclasses.dataclass(frozen=True) 61class Degree: 62 63 """ 64 A scale degree — 1-based, resolved against key + scale at placement. 65 66 Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the 67 scale length and resolve into higher octaves). ``octave`` shifts whole 68 octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened). 69 """ 70 71 step: int 72 octave: int = 0 73 chroma: int = 0 74 75 def __post_init__ (self) -> None: 76 77 """Validate that the degree is 1-based and plausibly a degree.""" 78 79 if self.step < 1: 80 raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")
83@dataclasses.dataclass(frozen=True) 84class ChordTone: 85 86 """ 87 An index into the current chord's tones — 1-based, resolved at placement. 88 89 Accepts an int (1 = root, 2 = third, ...) or one of the names 90 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave`` 91 shifts whole octaves. 92 """ 93 94 index: int 95 octave: int = 0 96 97 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 98 99 """Normalize a tone name to its 1-based index.""" 100 101 if isinstance(index_or_name, str): 102 if index_or_name not in _CHORD_TONE_NAMES: 103 raise ValueError( 104 f"Unknown chord tone name '{index_or_name}' — " 105 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 106 ) 107 index = _CHORD_TONE_NAMES[index_or_name] 108 else: 109 index = index_or_name 110 111 if index < 1: 112 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 113 114 object.__setattr__(self, "index", index) 115 object.__setattr__(self, "octave", octave)
An index into the current chord's tones — 1-based, resolved at placement.
Accepts an int (1 = root, 2 = third, ...) or one of the names
"root" / "third" / "fifth" / "seventh". octave
shifts whole octaves.
97 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 98 99 """Normalize a tone name to its 1-based index.""" 100 101 if isinstance(index_or_name, str): 102 if index_or_name not in _CHORD_TONE_NAMES: 103 raise ValueError( 104 f"Unknown chord tone name '{index_or_name}' — " 105 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 106 ) 107 index = _CHORD_TONE_NAMES[index_or_name] 108 else: 109 index = index_or_name 110 111 if index < 1: 112 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 113 114 object.__setattr__(self, "index", index) 115 object.__setattr__(self, "octave", octave)
Normalize a tone name to its 1-based index.
118@dataclasses.dataclass(frozen=True) 119class Approach: 120 121 """ 122 A half-step approach into a target pitch at the next chord boundary. 123 124 Parses today; resolution requires the harmony window and is not yet 125 available — placing a motif containing one raises with a clear message. 126 """ 127 128 target: typing.Union[int, Degree, ChordTone]
A half-step approach into a target pitch at the next chord boundary.
Parses today; resolution requires the harmony window and is not yet available — placing a motif containing one raises with a clear message.
228@dataclasses.dataclass(frozen=True) 229class MotifEvent: 230 231 """ 232 One timed note event inside a Motif. 233 234 ``pitch`` is a specification: an absolute MIDI int, a drum name string, 235 a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None 236 for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which 237 must be re-pitched via :meth:`Motif.pitched` before placement. 238 ``velocity`` is an int or a ``(low, high)`` random-range tuple. 239 """ 240 241 beat: float 242 pitch: PitchSpec 243 velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY 244 duration: float = 0.25 245 probability: float = 1.0 246 247 def __post_init__ (self) -> None: 248 249 """Validate ranges that are wrong at any placement.""" 250 251 if self.duration <= 0: 252 raise ValueError(f"Event duration must be positive — got {self.duration}") 253 if not 0.0 <= self.probability <= 1.0: 254 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 255 256 def _sort_key (self) -> tuple: 257 258 """Canonical ordering key — makes parallel merge order-independent.""" 259 260 return (self.beat, _pitch_sort_key(self.pitch), _velocity_key(self.velocity), self.duration, self.probability)
One timed note event inside a Motif.
pitch is a specification: an absolute MIDI int, a drum name string,
a Degree, ChordTone, or Approach — or None
for a pitch-stripped skeleton event (see Motif.rhythm()), which
must be re-pitched via Motif.pitched() before placement.
velocity is an int or a (low, high) random-range tuple.
263@dataclasses.dataclass(frozen=True) 264class ControlEvent: 265 266 """ 267 One timed control gesture inside a Motif: a discrete write or a shaped ramp. 268 269 A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates 270 ``start`` → ``end`` over ``span`` beats through the easing ``shape``. 271 Pulse density (``resolution=``) is deliberately not stored here — beats 272 and shapes are music; MIDI traffic density is set at the placement call. 273 """ 274 275 beat: float 276 signal: ControlSignal 277 start: float 278 end: typing.Optional[float] = None 279 span: float = 0.0 280 shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear" 281 probability: float = 1.0 282 283 def __post_init__ (self) -> None: 284 285 """Validate the discrete/ramp invariants.""" 286 287 if (self.end is None) != (self.span == 0.0): 288 raise ValueError("A ramp needs both end= and span= (a discrete write has neither)") 289 if self.span < 0: 290 raise ValueError(f"Ramp span must be non-negative — got {self.span}") 291 if not 0.0 <= self.probability <= 1.0: 292 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 293 294 def _sort_key (self) -> tuple: 295 296 """Canonical ordering key — makes parallel merge order-independent.""" 297 298 end = self.start if self.end is None else self.end 299 return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability) 300 301 def _value_at (self, fraction: float) -> float: 302 303 """The interpolated value at a 0–1 fraction through the ramp.""" 304 305 if self.end is None: 306 return self.start 307 308 easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape) 309 return self.start + (self.end - self.start) * easing_fn(max(0.0, min(1.0, fraction)))
One timed control gesture inside a Motif: a discrete write or a shaped ramp.
A discrete write has end=None and span=0.0; a ramp interpolates
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.
872@dataclasses.dataclass(frozen=True) 873class Progression: 874 875 """A frozen sequence of :class:`ChordSpan` — the governing harmony value. 876 877 Always a realised value: binding it to the clock freezes one realisation; 878 ``p.progression()`` keeps its breathing behaviour by re-realising a fresh 879 one each rebuild. Iterating yields ``(chord, start, length)`` 880 :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so 881 placement loops keep working unchanged. 882 883 The governing family supports ``+`` (concatenate) and ``*`` (tile) but 884 never ``&`` — there is one current chord (P1, the type law). 885 886 Attributes: 887 spans: The chord spans, in order. 888 trailing_history: Engine continuity metadata set by 889 :meth:`Composition.freeze` — the NIR history at capture time, 890 restored on each frozen replay. Empty for hand-built values. 891 """ 892 893 spans: typing.Tuple[ChordSpan, ...] 894 trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = () 895 896 def __post_init__ (self) -> None: 897 898 """Normalise span containers to tuples.""" 899 900 object.__setattr__(self, "spans", tuple(self.spans)) 901 object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) 902 903 if not self.spans: 904 raise ValueError("a Progression needs at least one chord span") 905 906 # -- queries ------------------------------------------------------------ 907 908 @property 909 def length (self) -> float: 910 911 """Total length in beats (the sum of span lengths).""" 912 913 return float(sum(span.beats for span in self.spans)) 914 915 @property 916 def is_concrete (self) -> bool: 917 918 """True when every span is key-independent (no romans/degrees).""" 919 920 return all(span.is_concrete for span in self.spans) 921 922 @property 923 def chords (self) -> typing.Tuple[typing.Any, ...]: 924 925 """The bare chords, one per span (concrete progressions only).""" 926 927 self._require_concrete("read .chords") 928 929 return tuple(span.chord for span in self.spans) 930 931 @property 932 def loops_on_exhaustion (self) -> bool: 933 934 """True when the clock must loop rather than fall through to live stepping.""" 935 936 return any(isinstance(span.chord, PitchSet) for span in self.spans) 937 938 def _require_concrete (self, action: str) -> None: 939 940 """Raise with a resolution hint when key-relative spans remain.""" 941 942 if not self.is_concrete: 943 relative = ", ".join(span.label() for span in self.spans if not span.is_concrete) 944 raise ValueError( 945 f"cannot {action} on a key-relative progression (contains {relative}) — " 946 "call .resolve(key=...) first, or bind it where a key is known" 947 ) 948 949 def __iter__ (self) -> typing.Iterator[ChordEvent]: 950 951 """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" 952 953 self._require_concrete("iterate") 954 955 cursor = 0.0 956 957 for span in self.spans: 958 chord = DecoratedChord(span) if span.is_decorated else span.chord 959 yield ChordEvent(chord=chord, start=cursor, length=span.beats) 960 cursor += span.beats 961 962 def __len__ (self) -> int: 963 964 """The number of chord spans.""" 965 966 return len(self.spans) 967 968 def events (self) -> typing.Tuple[ChordEvent, ...]: 969 970 """The realised timeline as a tuple (iteration, materialised).""" 971 972 return tuple(self) 973 974 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 975 976 """Return ``(span, start, end)`` for the span sounding at *beat*. 977 978 *beat* wraps modulo the progression length, so the lookup also 979 serves looped playback. 980 """ 981 982 position = beat % self.length 983 cursor = 0.0 984 985 for span in self.spans: 986 if cursor <= position < cursor + span.beats: 987 return span, cursor, cursor + span.beats 988 cursor += span.beats 989 990 final = self.spans[-1] 991 return final, self.length - final.beats, self.length 992 993 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 994 995 """Resolve every key-relative span against a key (name or pitch class).""" 996 997 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 998 999 return dataclasses.replace( 1000 self, 1001 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1002 ) 1003 1004 @classmethod 1005 def generate ( 1006 cls, 1007 style: typing.Union[str, typing.Any] = "functional_major", 1008 bars: int = 8, 1009 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1010 *, 1011 key: typing.Optional[str] = None, 1012 scale: typing.Optional[str] = None, 1013 seed: typing.Optional[int] = None, 1014 rng: typing.Optional[random.Random] = None, 1015 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1016 end: typing.Optional[typing.Any] = None, 1017 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1018 dominant_7th: bool = True, 1019 gravity: float = 1.0, 1020 nir_strength: float = 0.5, 1021 minor_turnaround_weight: float = 0.0, 1022 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1023 ) -> "Progression": 1024 1025 """Generate a progression from a chord-graph walk — the hybrid generator. 1026 1027 Full parameter pass-through to the engine (no more throwaway default 1028 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1029 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1030 everywhere. Constraints compile into the walk — a backward 1031 feasibility pass guarantees satisfiability before any chord is 1032 drawn (unsatisfiable constraints raise immediately), then a forward 1033 walk samples through the engine's real history-dependent weights 1034 (NIR, gravity, diversity keep their character). 1035 1036 **Without** ``key=`` the result is key-relative — the walk runs 1037 against a reference tonic and the spans store scale-proof 1038 major-relative romans, so the value prints meaningfully unbound and 1039 resolves wherever it is bound (the walk itself is key-invariant). 1040 **With** ``key=`` the result is concrete. 1041 1042 Parameters: 1043 style: A chord-graph style name (or ``ChordGraph`` instance). 1044 bars: How many chords to generate. 1045 beats: Span length per chord — a scalar, or a list cycled. 1046 key: Key for a concrete result; omit for a key-relative value. 1047 scale: Scale for int constraints' quality inference (e.g. 1048 ``end=1``). Defaults from the style (aeolian_minor → 1049 minor); explicit strings (``"V"``, ``"bVII7"``) never 1050 need it. 1051 seed: Seed for the walk. A standalone generated value without 1052 a seed warns — module-level nondeterminism breaks live 1053 reload. 1054 rng: An explicit random stream (overrides ``seed``). 1055 pins: ``{bar: chord}`` — 1-based; values parse like progression 1056 elements (ints, romans, names, ``Chord``). 1057 end: The chord at the final bar — ``end="V"`` is the cadential 1058 major dominant in minor (a string because it is chromatic; 1059 no int can ask for it). 1060 avoid: Chords excluded from the walk. Naming a chord outside 1061 the style's vocabulary is allowed (trivially satisfied). 1062 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1063 root_diversity: The engine parameters, exactly as 1064 :meth:`Composition.harmony` takes them. 1065 1066 Example: 1067 ```python 1068 chorus = subsequence.Progression.generate( 1069 style="aeolian_minor", bars=4, end="V", seed=7, 1070 ) 1071 print(chorus) # romans until bound 1072 ``` 1073 """ 1074 1075 if bars < 1: 1076 raise ValueError("bars must be at least 1") 1077 1078 if rng is None: 1079 if seed is None: 1080 warnings.warn( 1081 "Progression.generate without seed= is nondeterministic — " 1082 "pass seed= so the value survives live reload", 1083 stacklevel = 2, 1084 ) 1085 rng = random.Random() 1086 else: 1087 rng = random.Random(seed) 1088 1089 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1090 relative = key is None 1091 reference = key if key is not None else "C" 1092 1093 state = subsequence.harmonic_state.HarmonicState( 1094 key_name = reference, 1095 graph_style = style, 1096 include_dominant_7th = dominant_7th, 1097 key_gravity_blend = gravity, 1098 nir_strength = nir_strength, 1099 minor_turnaround_weight = minor_turnaround_weight, 1100 root_diversity = root_diversity, 1101 rng = rng, 1102 ) 1103 1104 resolved_pins = { 1105 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1106 for position, spec in (pins or {}).items() 1107 } 1108 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1109 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1110 1111 if 1 in resolved_pins: 1112 if resolved_pins[1] not in state.graph.nodes(): 1113 raise ValueError( 1114 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1115 ) 1116 state.current_chord = resolved_pins[1] 1117 1118 def commit (chosen: subsequence.chords.Chord) -> None: 1119 state.current_chord = chosen 1120 1121 walked = subsequence.sequence_utils.constrained_walk( 1122 state.graph, 1123 state.current_chord, 1124 bars, 1125 rng = state.rng, 1126 pins = resolved_pins, 1127 end = resolved_end, 1128 avoid = resolved_avoid, 1129 weight_modifier = state._transition_weight, 1130 before_choice = state._record_transition_source, 1131 after_choice = commit, 1132 ) 1133 1134 lengths = _span_lengths(beats, bars) 1135 1136 if relative: 1137 return cls(spans = tuple( 1138 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1139 for index, chord in enumerate(walked) 1140 )) 1141 1142 return cls(spans = tuple( 1143 ChordSpan(chord = chord, beats = lengths[index]) 1144 for index, chord in enumerate(walked) 1145 )) 1146 1147 # -- algebra ------------------------------------------------------------ 1148 1149 def __add__ (self, other: "Progression") -> "Progression": 1150 1151 """Concatenate two progressions (the governing ``+``).""" 1152 1153 if not isinstance(other, Progression): 1154 return NotImplemented 1155 1156 return Progression(spans = self.spans + other.spans) 1157 1158 def __mul__ (self, count: int) -> "Progression": 1159 1160 """Tile the spans *count* times.""" 1161 1162 if not isinstance(count, int) or isinstance(count, bool): 1163 return NotImplemented 1164 if count < 1: 1165 raise ValueError("a progression must repeat at least once (n >= 1)") 1166 1167 return Progression(spans = self.spans * count) 1168 1169 def __and__ (self, other: typing.Any) -> "Progression": 1170 1171 """Parallel merge is a type error for governing values — by design.""" 1172 1173 raise TypeError( 1174 "Progressions cannot be merged with & — there is one current chord. " 1175 "Sequence them with +, or give a pattern its own part-level progression." 1176 ) 1177 1178 # -- spice (the five operators) and editing ------------------------------ 1179 1180 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1181 1182 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1183 1184 ``only=`` restricts the spice to the given 1-based chord slots. 1185 """ 1186 1187 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1188 1189 spans = tuple( 1190 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1191 if index in slots else span 1192 for index, span in enumerate(self.spans) 1193 ) 1194 1195 return dataclasses.replace(self, spans=spans) 1196 1197 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1198 1199 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1200 1201 values = [spec] if isinstance(spec, int) else list(spec) 1202 1203 if not values: 1204 raise ValueError("inversions list is empty — pass at least one inversion") 1205 1206 spans = tuple( 1207 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1208 for index, span in enumerate(self.spans) 1209 ) 1210 1211 return dataclasses.replace(self, spans=spans) 1212 1213 def spread (self, style: str) -> "Progression": 1214 1215 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1216 1217 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1218 1219 return dataclasses.replace(self, spans=spans) 1220 1221 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1222 1223 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1224 1225 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"`` 1226 (resolved against the key at query time). ``only=`` restricts it to 1227 the given 1-based slots (slash chords rather than a full pedal). 1228 """ 1229 1230 if isinstance(bass, str) and bass != "tonic": 1231 subsequence.chords.key_name_to_pc(bass) # validate early; resolution stays late 1232 elif isinstance(bass, int) and not 0 <= bass <= 11: 1233 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1234 1235 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1236 1237 spans = tuple( 1238 dataclasses.replace(span, bass=bass) if index in slots else span 1239 for index, span in enumerate(self.spans) 1240 ) 1241 1242 return dataclasses.replace(self, spans=spans) 1243 1244 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1245 1246 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1247 1248 Modal interchange for key-relative content: the degree re-resolves 1249 against the parallel mode (minor under a major scale and vice 1250 versa). Concrete chords raise — there is nothing relative to borrow. 1251 """ 1252 1253 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1254 1255 spans = list(self.spans) 1256 1257 for index in slots: 1258 chord = spans[index].chord 1259 if not isinstance(chord, RomanChord): 1260 raise ValueError( 1261 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1262 "borrow() needs key-relative content (an int degree or roman)" 1263 ) 1264 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1265 1266 return dataclasses.replace(self, spans=tuple(spans)) 1267 1268 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1269 1270 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1271 1272 index = _check_slot(slot, len(self.spans)) 1273 parsed = parse_element(chord, beats = self.spans[index].beats) 1274 1275 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1276 1277 return dataclasses.replace(self, spans=spans) 1278 1279 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1280 1281 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1282 1283 if isinstance(beats, bool): 1284 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1285 1286 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1287 1288 if not values: 1289 raise ValueError("with_rhythm list is empty — pass at least one length") 1290 1291 spans = tuple( 1292 dataclasses.replace(span, beats = float(values[index % len(values)])) 1293 for index, span in enumerate(self.spans) 1294 ) 1295 1296 return dataclasses.replace(self, spans=spans) 1297 1298 # -- description ---------------------------------------------------------- 1299 1300 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1301 1302 """A readable, one-chord-per-line summary. 1303 1304 Key-relative spans print as written (romans/degrees) when unbound, 1305 and as concrete chord names under a *key*. 1306 """ 1307 1308 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1309 1310 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1311 cursor = 0.0 1312 1313 for span in self.spans: 1314 lines.append( 1315 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1316 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1317 ) 1318 cursor += span.beats 1319 1320 return "\n".join(lines) 1321 1322 def __str__ (self) -> str: 1323 1324 """Same as :meth:`describe` with no key bound.""" 1325 1326 return self.describe()
A frozen sequence of ChordSpan — the governing harmony value.
Always a realised value: binding it to the clock freezes one realisation;
p.progression() keeps its breathing behaviour by re-realising a fresh
one each rebuild. Iterating yields (chord, start, length)
ChordEvent tuples (the old ChordTimeline contract), so
placement loops keep working unchanged.
The governing family supports + (concatenate) and * (tile) but
never & — there is one current chord (P1, the type law).
Attributes:
- spans: The chord spans, in order.
- trailing_history: Engine continuity metadata set by
Composition.freeze()— the NIR history at capture time, restored on each frozen replay. Empty for hand-built values.
908 @property 909 def length (self) -> float: 910 911 """Total length in beats (the sum of span lengths).""" 912 913 return float(sum(span.beats for span in self.spans))
Total length in beats (the sum of span lengths).
915 @property 916 def is_concrete (self) -> bool: 917 918 """True when every span is key-independent (no romans/degrees).""" 919 920 return all(span.is_concrete for span in self.spans)
True when every span is key-independent (no romans/degrees).
922 @property 923 def chords (self) -> typing.Tuple[typing.Any, ...]: 924 925 """The bare chords, one per span (concrete progressions only).""" 926 927 self._require_concrete("read .chords") 928 929 return tuple(span.chord for span in self.spans)
The bare chords, one per span (concrete progressions only).
931 @property 932 def loops_on_exhaustion (self) -> bool: 933 934 """True when the clock must loop rather than fall through to live stepping.""" 935 936 return any(isinstance(span.chord, PitchSet) for span in self.spans)
True when the clock must loop rather than fall through to live stepping.
968 def events (self) -> typing.Tuple[ChordEvent, ...]: 969 970 """The realised timeline as a tuple (iteration, materialised).""" 971 972 return tuple(self)
The realised timeline as a tuple (iteration, materialised).
974 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 975 976 """Return ``(span, start, end)`` for the span sounding at *beat*. 977 978 *beat* wraps modulo the progression length, so the lookup also 979 serves looped playback. 980 """ 981 982 position = beat % self.length 983 cursor = 0.0 984 985 for span in self.spans: 986 if cursor <= position < cursor + span.beats: 987 return span, cursor, cursor + span.beats 988 cursor += span.beats 989 990 final = self.spans[-1] 991 return final, self.length - final.beats, self.length
Return (span, start, end) for the span sounding at beat.
beat wraps modulo the progression length, so the lookup also serves looped playback.
993 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 994 995 """Resolve every key-relative span against a key (name or pitch class).""" 996 997 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 998 999 return dataclasses.replace( 1000 self, 1001 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1002 )
Resolve every key-relative span against a key (name or pitch class).
1004 @classmethod 1005 def generate ( 1006 cls, 1007 style: typing.Union[str, typing.Any] = "functional_major", 1008 bars: int = 8, 1009 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1010 *, 1011 key: typing.Optional[str] = None, 1012 scale: typing.Optional[str] = None, 1013 seed: typing.Optional[int] = None, 1014 rng: typing.Optional[random.Random] = None, 1015 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1016 end: typing.Optional[typing.Any] = None, 1017 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1018 dominant_7th: bool = True, 1019 gravity: float = 1.0, 1020 nir_strength: float = 0.5, 1021 minor_turnaround_weight: float = 0.0, 1022 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1023 ) -> "Progression": 1024 1025 """Generate a progression from a chord-graph walk — the hybrid generator. 1026 1027 Full parameter pass-through to the engine (no more throwaway default 1028 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1029 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1030 everywhere. Constraints compile into the walk — a backward 1031 feasibility pass guarantees satisfiability before any chord is 1032 drawn (unsatisfiable constraints raise immediately), then a forward 1033 walk samples through the engine's real history-dependent weights 1034 (NIR, gravity, diversity keep their character). 1035 1036 **Without** ``key=`` the result is key-relative — the walk runs 1037 against a reference tonic and the spans store scale-proof 1038 major-relative romans, so the value prints meaningfully unbound and 1039 resolves wherever it is bound (the walk itself is key-invariant). 1040 **With** ``key=`` the result is concrete. 1041 1042 Parameters: 1043 style: A chord-graph style name (or ``ChordGraph`` instance). 1044 bars: How many chords to generate. 1045 beats: Span length per chord — a scalar, or a list cycled. 1046 key: Key for a concrete result; omit for a key-relative value. 1047 scale: Scale for int constraints' quality inference (e.g. 1048 ``end=1``). Defaults from the style (aeolian_minor → 1049 minor); explicit strings (``"V"``, ``"bVII7"``) never 1050 need it. 1051 seed: Seed for the walk. A standalone generated value without 1052 a seed warns — module-level nondeterminism breaks live 1053 reload. 1054 rng: An explicit random stream (overrides ``seed``). 1055 pins: ``{bar: chord}`` — 1-based; values parse like progression 1056 elements (ints, romans, names, ``Chord``). 1057 end: The chord at the final bar — ``end="V"`` is the cadential 1058 major dominant in minor (a string because it is chromatic; 1059 no int can ask for it). 1060 avoid: Chords excluded from the walk. Naming a chord outside 1061 the style's vocabulary is allowed (trivially satisfied). 1062 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1063 root_diversity: The engine parameters, exactly as 1064 :meth:`Composition.harmony` takes them. 1065 1066 Example: 1067 ```python 1068 chorus = subsequence.Progression.generate( 1069 style="aeolian_minor", bars=4, end="V", seed=7, 1070 ) 1071 print(chorus) # romans until bound 1072 ``` 1073 """ 1074 1075 if bars < 1: 1076 raise ValueError("bars must be at least 1") 1077 1078 if rng is None: 1079 if seed is None: 1080 warnings.warn( 1081 "Progression.generate without seed= is nondeterministic — " 1082 "pass seed= so the value survives live reload", 1083 stacklevel = 2, 1084 ) 1085 rng = random.Random() 1086 else: 1087 rng = random.Random(seed) 1088 1089 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1090 relative = key is None 1091 reference = key if key is not None else "C" 1092 1093 state = subsequence.harmonic_state.HarmonicState( 1094 key_name = reference, 1095 graph_style = style, 1096 include_dominant_7th = dominant_7th, 1097 key_gravity_blend = gravity, 1098 nir_strength = nir_strength, 1099 minor_turnaround_weight = minor_turnaround_weight, 1100 root_diversity = root_diversity, 1101 rng = rng, 1102 ) 1103 1104 resolved_pins = { 1105 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1106 for position, spec in (pins or {}).items() 1107 } 1108 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1109 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1110 1111 if 1 in resolved_pins: 1112 if resolved_pins[1] not in state.graph.nodes(): 1113 raise ValueError( 1114 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1115 ) 1116 state.current_chord = resolved_pins[1] 1117 1118 def commit (chosen: subsequence.chords.Chord) -> None: 1119 state.current_chord = chosen 1120 1121 walked = subsequence.sequence_utils.constrained_walk( 1122 state.graph, 1123 state.current_chord, 1124 bars, 1125 rng = state.rng, 1126 pins = resolved_pins, 1127 end = resolved_end, 1128 avoid = resolved_avoid, 1129 weight_modifier = state._transition_weight, 1130 before_choice = state._record_transition_source, 1131 after_choice = commit, 1132 ) 1133 1134 lengths = _span_lengths(beats, bars) 1135 1136 if relative: 1137 return cls(spans = tuple( 1138 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1139 for index, chord in enumerate(walked) 1140 )) 1141 1142 return cls(spans = tuple( 1143 ChordSpan(chord = chord, beats = lengths[index]) 1144 for index, chord in enumerate(walked) 1145 ))
Generate a progression from a chord-graph walk — the hybrid generator.
Full parameter pass-through to the engine (no more throwaway default
engines), plus the hybrid constraints: pins fix chords at 1-based
bars, end fixes the last bar, avoid excludes chords
everywhere. Constraints compile into the walk — a backward
feasibility pass guarantees satisfiability before any chord is
drawn (unsatisfiable constraints raise immediately), then a forward
walk samples through the engine's real history-dependent weights
(NIR, gravity, diversity keep their character).
Without key= the result is key-relative — the walk runs
against a reference tonic and the spans store scale-proof
major-relative romans, so the value prints meaningfully unbound and
resolves wherever it is bound (the walk itself is key-invariant).
With key= the result is concrete.
Arguments:
- style: A chord-graph style name (or
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).
- 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
1180 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1181 1182 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1183 1184 ``only=`` restricts the spice to the given 1-based chord slots. 1185 """ 1186 1187 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1188 1189 spans = tuple( 1190 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1191 if index in slots else span 1192 for index, span in enumerate(self.spans) 1193 ) 1194 1195 return dataclasses.replace(self, spans=spans)
Add chord extensions (7/9/11/13/"sus4"/...) to every span.
only= restricts the spice to the given 1-based chord slots.
1197 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1198 1199 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1200 1201 values = [spec] if isinstance(spec, int) else list(spec) 1202 1203 if not values: 1204 raise ValueError("inversions list is empty — pass at least one inversion") 1205 1206 spans = tuple( 1207 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1208 for index, span in enumerate(self.spans) 1209 ) 1210 1211 return dataclasses.replace(self, spans=spans)
Set chord inversions — a single int for all spans, or a list cycled per span.
1213 def spread (self, style: str) -> "Progression": 1214 1215 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1216 1217 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1218 1219 return dataclasses.replace(self, spans=spans)
Set the voicing spread: "close", "open" (drop-2), or "wide".
1221 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1222 1223 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1224 1225 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"`` 1226 (resolved against the key at query time). ``only=`` restricts it to 1227 the given 1-based slots (slash chords rather than a full pedal). 1228 """ 1229 1230 if isinstance(bass, str) and bass != "tonic": 1231 subsequence.chords.key_name_to_pc(bass) # validate early; resolution stays late 1232 elif isinstance(bass, int) and not 0 <= bass <= 11: 1233 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1234 1235 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1236 1237 spans = tuple( 1238 dataclasses.replace(span, bass=bass) if index in slots else span 1239 for index, span in enumerate(self.spans) 1240 ) 1241 1242 return dataclasses.replace(self, spans=spans)
Put the progression over a slash/pedal bass — the trance/techno move.
bass is a pitch class int, a note name ("G"), or "tonic"
(resolved against the key at query time). only= restricts it to
the given 1-based slots (slash chords rather than a full pedal).
1244 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1245 1246 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1247 1248 Modal interchange for key-relative content: the degree re-resolves 1249 against the parallel mode (minor under a major scale and vice 1250 versa). Concrete chords raise — there is nothing relative to borrow. 1251 """ 1252 1253 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1254 1255 spans = list(self.spans) 1256 1257 for index in slots: 1258 chord = spans[index].chord 1259 if not isinstance(chord, RomanChord): 1260 raise ValueError( 1261 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1262 "borrow() needs key-relative content (an int degree or roman)" 1263 ) 1264 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1265 1266 return dataclasses.replace(self, spans=tuple(spans))
Borrow the chord(s) at the given 1-based slot(s) from the parallel scale.
Modal interchange for key-relative content: the degree re-resolves against the parallel mode (minor under a major scale and vice versa). Concrete chords raise — there is nothing relative to borrow.
1268 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1269 1270 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1271 1272 index = _check_slot(slot, len(self.spans)) 1273 parsed = parse_element(chord, beats = self.spans[index].beats) 1274 1275 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1276 1277 return dataclasses.replace(self, spans=spans)
Replace the chord at a 1-based slot (the span keeps its beats).
1279 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1280 1281 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1282 1283 if isinstance(beats, bool): 1284 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1285 1286 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1287 1288 if not values: 1289 raise ValueError("with_rhythm list is empty — pass at least one length") 1290 1291 spans = tuple( 1292 dataclasses.replace(span, beats = float(values[index % len(values)])) 1293 for index, span in enumerate(self.spans) 1294 ) 1295 1296 return dataclasses.replace(self, spans=spans)
Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.
1300 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1301 1302 """A readable, one-chord-per-line summary. 1303 1304 Key-relative spans print as written (romans/degrees) when unbound, 1305 and as concrete chord names under a *key*. 1306 """ 1307 1308 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1309 1310 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1311 cursor = 0.0 1312 1313 for span in self.spans: 1314 lines.append( 1315 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1316 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1317 ) 1318 cursor += span.beats 1319 1320 return "\n".join(lines)
A readable, one-chord-per-line summary.
Key-relative spans print as written (romans/degrees) when unbound, and as concrete chord names under a key.
384@dataclasses.dataclass(frozen=True) 385class ChordSpan: 386 387 """One chord with a duration and its decoration — the unit of harmonic time. 388 389 Decoration (extensions, slash bass, inversion, spread) lives HERE, never 390 on :class:`~subsequence.chords.Chord`: the engine's graph identity stays 391 the bare triad, and the decorated voicing is what patterns hear. 392 393 Attributes: 394 chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a 395 :class:`PitchSet`. 396 beats: Span length in beats. 397 extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) 398 or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). 399 bass: Slash/pedal bass — a pitch class int, a note name, or 400 ``"tonic"`` (resolved against the key at query time). 401 inversion: Chord inversion for the voicing (0 = root position). 402 spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), 403 or ``"wide"`` (drop-2-and-4). 404 extension_intervals: Pre-computed semitone offsets for the 405 extensions, set by :meth:`Progression.resolve` for diatonic 406 degrees. ``None`` means "derive from the chord's own colour". 407 """ 408 409 chord: typing.Any 410 beats: float 411 extensions: typing.Tuple[typing.Any, ...] = () 412 bass: typing.Optional[typing.Union[int, str]] = None 413 inversion: int = 0 414 spread: typing.Optional[str] = None 415 extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None 416 417 def __post_init__ (self) -> None: 418 419 """Validate beats, extensions, and spread.""" 420 421 if self.beats <= 0: 422 raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}") 423 424 for extension in self.extensions: 425 if isinstance(extension, bool) or not ( 426 (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) 427 or (isinstance(extension, str) and extension in _EXTENSION_NAMES) 428 ): 429 known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) 430 raise ValueError(f"unknown extension {extension!r} — expected one of: {known}") 431 432 if self.spread is not None and self.spread not in _SPREAD_STYLES: 433 raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES))) 434 435 @property 436 def is_concrete (self) -> bool: 437 438 """True when the chord needs no key context to sound.""" 439 440 return not isinstance(self.chord, RomanChord) 441 442 @property 443 def is_decorated (self) -> bool: 444 445 """True when the span carries any decoration beyond the bare chord.""" 446 447 return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None 448 449 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 450 451 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 452 453 chord = self.chord 454 extension_intervals = self.extension_intervals 455 456 if isinstance(chord, RomanChord): 457 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 458 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 459 chord = chord.resolve(key_pc, scale) 460 461 bass: typing.Optional[typing.Union[int, str]] = self.bass 462 463 if isinstance(bass, str): 464 if bass == "tonic": 465 bass = key_pc 466 else: 467 bass = subsequence.chords.key_name_to_pc(bass) 468 469 return dataclasses.replace( 470 self, 471 chord = chord, 472 bass = bass, 473 extension_intervals = extension_intervals, 474 ) 475 476 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 477 478 """A printable chord label: roman text when relative, decorated name when concrete.""" 479 480 if isinstance(self.chord, RomanChord): 481 if key_pc is None: 482 text = self.chord.label() 483 return text + self._decoration_suffix(resolved=False) 484 return self.resolve(key_pc, scale).label() 485 486 base = str(self.chord.name()) 487 return base + self._decoration_suffix(resolved=True) 488 489 def _decoration_suffix (self, resolved: bool) -> str: 490 491 """The printable decoration tail (extensions and slash bass).""" 492 493 parts = "" 494 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 495 496 # 9 implies 7 (and so on up): print only the highest stacked extension. 497 stacked = [e for e in numeric if e in (7, 9, 11, 13)] 498 if stacked: 499 parts += str(stacked[-1]) 500 501 for name in (e for e in self.extensions if isinstance(e, str)): 502 parts += name 503 504 if self.bass is not None: 505 if isinstance(self.bass, int): 506 parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] 507 else: 508 parts += "/" + str(self.bass) 509 510 return parts 511 512 def decorated_intervals (self) -> typing.List[int]: 513 514 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 515 516 Numeric extensions deepen the chord in its own colour — a minor third 517 gets a minor seventh, a major third a major seventh, a diminished 518 triad a diminished seventh. Diatonic degrees extended with 519 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 520 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 521 dominant colour on a concrete major chord. 522 """ 523 524 if isinstance(self.chord, RomanChord): 525 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 526 527 intervals = list(self.chord.intervals()) 528 529 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 530 if sus and len(intervals) >= 2: 531 intervals[1] = 2 if sus[0] == "sus2" else 5 532 533 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 534 535 if self.extension_intervals is not None: 536 added: typing.List[int] = list(self.extension_intervals) 537 else: 538 added = [] 539 third = intervals[1] if len(intervals) >= 2 else None 540 has_seventh = any(i in (9, 10, 11) for i in intervals) 541 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 542 543 if stacked and not has_seventh: 544 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 545 added.append(9) # diminished colour 546 elif third == 3: 547 added.append(10) # minor colour 548 elif third == 4: 549 added.append(11) # major colour 550 else: 551 added.append(10) # sus / no third: the dominant-leaning seventh 552 553 for extension in stacked: 554 if extension == 9: 555 added.append(14) 556 elif extension == 11: 557 added.append(17) 558 elif extension == 13: 559 added.append(21) 560 561 if "add9" in self.extensions: 562 added.append(14) 563 if "6" in self.extensions: 564 added.append(9) 565 566 return sorted(set(intervals) | set(added)) 567 568 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 569 570 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 571 572 Applies, in order: extensions, inversion, spread, then the slash/pedal 573 bass below the voicing. ``PitchSet`` spans return their absolute 574 pitches (decoration other than ``count`` does not apply). 575 """ 576 577 if isinstance(self.chord, RomanChord): 578 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 579 580 if isinstance(self.chord, PitchSet): 581 return self.chord.tones(root, inversion=self.inversion, count=count) 582 583 intervals = self.decorated_intervals() 584 585 if self.inversion != 0: 586 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 587 588 if self.spread == "open" and len(intervals) >= 3: 589 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 590 elif self.spread == "wide" and len(intervals) >= 3: 591 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 592 intervals = sorted(dropped) 593 594 offset = (self.chord.root_pc - root) % 12 595 if offset > 6: 596 offset -= 12 597 effective_root = root + offset 598 599 if count is not None: 600 n = len(intervals) 601 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 602 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 603 else: 604 pitches = [effective_root + interval for interval in intervals] 605 606 if self.bass is not None and isinstance(self.bass, int): 607 lowest = min(pitches) 608 bass_note = lowest - ((lowest - self.bass) % 12) 609 if bass_note == lowest: 610 bass_note -= 12 611 pitches = [bass_note] + pitches 612 613 return pitches
One chord with a duration and its decoration — the unit of harmonic time.
Decoration (extensions, slash bass, inversion, spread) lives HERE, never
on ~subsequence.chords.Chord: the engine's graph identity stays
the bare triad, and the decorated voicing is what patterns hear.
Attributes:
- chord: A concrete
Chord, a key-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".
435 @property 436 def is_concrete (self) -> bool: 437 438 """True when the chord needs no key context to sound.""" 439 440 return not isinstance(self.chord, RomanChord)
True when the chord needs no key context to sound.
442 @property 443 def is_decorated (self) -> bool: 444 445 """True when the span carries any decoration beyond the bare chord.""" 446 447 return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None
True when the span carries any decoration beyond the bare chord.
449 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 450 451 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 452 453 chord = self.chord 454 extension_intervals = self.extension_intervals 455 456 if isinstance(chord, RomanChord): 457 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 458 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 459 chord = chord.resolve(key_pc, scale) 460 461 bass: typing.Optional[typing.Union[int, str]] = self.bass 462 463 if isinstance(bass, str): 464 if bass == "tonic": 465 bass = key_pc 466 else: 467 bass = subsequence.chords.key_name_to_pc(bass) 468 469 return dataclasses.replace( 470 self, 471 chord = chord, 472 bass = bass, 473 extension_intervals = extension_intervals, 474 )
Return a concrete span: romans resolved, bass resolved to a pitch class.
476 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 477 478 """A printable chord label: roman text when relative, decorated name when concrete.""" 479 480 if isinstance(self.chord, RomanChord): 481 if key_pc is None: 482 text = self.chord.label() 483 return text + self._decoration_suffix(resolved=False) 484 return self.resolve(key_pc, scale).label() 485 486 base = str(self.chord.name()) 487 return base + self._decoration_suffix(resolved=True)
A printable chord label: roman text when relative, decorated name when concrete.
512 def decorated_intervals (self) -> typing.List[int]: 513 514 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 515 516 Numeric extensions deepen the chord in its own colour — a minor third 517 gets a minor seventh, a major third a major seventh, a diminished 518 triad a diminished seventh. Diatonic degrees extended with 519 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 520 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 521 dominant colour on a concrete major chord. 522 """ 523 524 if isinstance(self.chord, RomanChord): 525 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 526 527 intervals = list(self.chord.intervals()) 528 529 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 530 if sus and len(intervals) >= 2: 531 intervals[1] = 2 if sus[0] == "sus2" else 5 532 533 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 534 535 if self.extension_intervals is not None: 536 added: typing.List[int] = list(self.extension_intervals) 537 else: 538 added = [] 539 third = intervals[1] if len(intervals) >= 2 else None 540 has_seventh = any(i in (9, 10, 11) for i in intervals) 541 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 542 543 if stacked and not has_seventh: 544 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 545 added.append(9) # diminished colour 546 elif third == 3: 547 added.append(10) # minor colour 548 elif third == 4: 549 added.append(11) # major colour 550 else: 551 added.append(10) # sus / no third: the dominant-leaning seventh 552 553 for extension in stacked: 554 if extension == 9: 555 added.append(14) 556 elif extension == 11: 557 added.append(17) 558 elif extension == 13: 559 added.append(21) 560 561 if "add9" in self.extensions: 562 added.append(14) 563 if "6" in self.extensions: 564 added.append(9) 565 566 return sorted(set(intervals) | set(added))
Semitone offsets of the decorated voicing (before inversion/spread/bass).
Numeric extensions deepen the chord in its own colour — a minor third
gets a minor seventh, a major third a major seventh, a diminished
triad a diminished seventh. Diatonic degrees extended with
extend(...) carry pre-computed scale-true intervals instead (so V
gets its dominant seventh). Write "G7"/"V7" when you want the
dominant colour on a concrete major chord.
568 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 569 570 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 571 572 Applies, in order: extensions, inversion, spread, then the slash/pedal 573 bass below the voicing. ``PitchSet`` spans return their absolute 574 pitches (decoration other than ``count`` does not apply). 575 """ 576 577 if isinstance(self.chord, RomanChord): 578 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 579 580 if isinstance(self.chord, PitchSet): 581 return self.chord.tones(root, inversion=self.inversion, count=count) 582 583 intervals = self.decorated_intervals() 584 585 if self.inversion != 0: 586 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 587 588 if self.spread == "open" and len(intervals) >= 3: 589 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 590 elif self.spread == "wide" and len(intervals) >= 3: 591 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 592 intervals = sorted(dropped) 593 594 offset = (self.chord.root_pc - root) % 12 595 if offset > 6: 596 offset -= 12 597 effective_root = root + offset 598 599 if count is not None: 600 n = len(intervals) 601 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 602 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 603 else: 604 pitches = [effective_root + interval for interval in intervals] 605 606 if self.bass is not None and isinstance(self.bass, int): 607 lowest = min(pitches) 608 bass_note = lowest - ((lowest - self.bass) % 12) 609 if bass_note == lowest: 610 bass_note -= 12 611 pitches = [bass_note] + pitches 612 613 return pitches
MIDI notes of the decorated voicing nearest root (concrete spans only).
Applies, in order: extensions, inversion, spread, then the slash/pedal
bass below the voicing. PitchSet spans return their absolute
pitches (decoration other than count does not apply).
80@dataclasses.dataclass(frozen=True) 81class PitchSet: 82 83 """A nameless sonority — a frozen set of absolute MIDI pitches. 84 85 The escape hatch for chords with no root or quality: clusters, spectral 86 stacks, found objects. It duck-types ``.tones()`` so every placement verb 87 and the injected ``chord`` accept it unchanged. By design it is excluded 88 from generation and diatonic spice (there is nothing to transpose 89 diatonically), and a progression containing one loops on exhaustion 90 rather than falling through to live graph stepping. 91 92 Pitches are absolute: ``tones()`` ignores its ``root`` argument — you 93 chose the register when you chose the pitches. 94 """ 95 96 pitches: typing.Tuple[int, ...] 97 98 def __init__ (self, pitches: typing.Iterable[int]) -> None: 99 100 """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" 101 102 values = tuple(sorted(int(p) for p in pitches)) 103 104 if not values: 105 raise ValueError("PitchSet needs at least one pitch") 106 107 object.__setattr__(self, "pitches", values) 108 109 def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 110 111 """Return the pitches (absolute — *root* is ignored by design). 112 113 ``inversion`` rotates pitches up an octave; ``count`` cycles the set 114 into higher octaves, matching the ``Chord.tones`` contract. 115 """ 116 117 pitches = list(self.pitches) 118 119 if inversion != 0: 120 for _ in range(inversion % len(pitches)): 121 pitches.append(pitches.pop(0) + 12) 122 123 if count is not None: 124 n = len(pitches) 125 return [pitches[i % n] + 12 * (i // n) for i in range(count)] 126 127 return pitches 128 129 def intervals (self) -> typing.List[int]: 130 131 """Semitone offsets from the lowest pitch (the ``Chord`` protocol).""" 132 133 return [p - self.pitches[0] for p in self.pitches] 134 135 def name (self) -> str: 136 137 """A readable label for describe() output.""" 138 139 return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")"
A nameless sonority — a frozen set of absolute MIDI pitches.
The escape hatch for chords with no root or quality: clusters, spectral
stacks, found objects. It duck-types .tones() so every placement verb
and the injected chord accept it unchanged. By design it is excluded
from generation and diatonic spice (there is nothing to transpose
diatonically), and a progression containing one loops on exhaustion
rather than falling through to live graph stepping.
Pitches are absolute: tones() ignores its root argument — you
chose the register when you chose the pitches.
98 def __init__ (self, pitches: typing.Iterable[int]) -> None: 99 100 """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" 101 102 values = tuple(sorted(int(p) for p in pitches)) 103 104 if not values: 105 raise ValueError("PitchSet needs at least one pitch") 106 107 object.__setattr__(self, "pitches", values)
Normalise any iterable of MIDI pitches into a sorted frozen tuple.
109 def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 110 111 """Return the pitches (absolute — *root* is ignored by design). 112 113 ``inversion`` rotates pitches up an octave; ``count`` cycles the set 114 into higher octaves, matching the ``Chord.tones`` contract. 115 """ 116 117 pitches = list(self.pitches) 118 119 if inversion != 0: 120 for _ in range(inversion % len(pitches)): 121 pitches.append(pitches.pop(0) + 12) 122 123 if count is not None: 124 n = len(pitches) 125 return [pitches[i % n] + 12 * (i // n) for i in range(count)] 126 127 return pitches
Return the pitches (absolute — root is ignored by design).
inversion rotates pitches up an octave; count cycles the set
into higher octaves, matching the Chord.tones contract.
1352def progression ( 1353 source: typing.Optional[typing.Any] = None, 1354 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1355 *, 1356 style: typing.Optional[str] = None, 1357 bars: int = 8, 1358 key: typing.Optional[str] = None, 1359 scale: typing.Optional[str] = None, 1360 seed: typing.Optional[int] = None, 1361 rng: typing.Optional[random.Random] = None, 1362 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1363 end: typing.Optional[typing.Any] = None, 1364 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1365 dominant_7th: bool = True, 1366 gravity: float = 1.0, 1367 nir_strength: float = 0.5, 1368 minor_turnaround_weight: float = 0.0, 1369 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1370) -> Progression: 1371 1372 """Build a :class:`Progression` — the lowercase factory. 1373 1374 Dispatch by argument type: a **list** parses per element (ints where 1375 diatonic, name/roman strings where nominal/chromatic, ``(element, 1376 beats)`` tuples for per-chord durations); a bare **string** names a 1377 preset from the curated table; ``style=`` generates *bars* chords from a 1378 chord-graph walk (requires ``key=``). 1379 1380 Parameters: 1381 source: The element list, preset name, or an existing Progression 1382 (returned unchanged). 1383 beats: Span length per chord — a scalar, or a list cycled per chord 1384 (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). 1385 style: A chord-graph style name to generate from (e.g. 1386 ``"aeolian_minor"``). 1387 bars: How many chords to generate (style mode only). 1388 key: Key for style generation. 1389 seed: Seed for style generation. A standalone generated value 1390 without a seed warns — module-level nondeterminism breaks live 1391 reload. 1392 rng: An explicit random stream (overrides ``seed``; used by 1393 engine-mediated calls). 1394 dominant_7th / gravity / nir_strength: Graph-walk parameters, 1395 matching :meth:`Composition.harmony` (style mode only; full 1396 pass-through arrives with ``Progression.generate``). 1397 1398 Example: 1399 ```python 1400 verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor 1401 blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) 1402 walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) 1403 ``` 1404 """ 1405 1406 if style is not None: 1407 if source is not None: 1408 raise ValueError("pass either source or style=, not both") 1409 return Progression.generate( 1410 style = style, 1411 bars = bars, 1412 beats = beats, 1413 key = key, 1414 scale = scale, 1415 seed = seed, 1416 rng = rng, 1417 pins = pins, 1418 end = end, 1419 avoid = avoid, 1420 dominant_7th = dominant_7th, 1421 gravity = gravity, 1422 nir_strength = nir_strength, 1423 minor_turnaround_weight = minor_turnaround_weight, 1424 root_diversity = root_diversity, 1425 ) 1426 1427 if isinstance(source, Progression): 1428 return source 1429 1430 if isinstance(source, str): 1431 if source in _PRESETS: 1432 return progression(_PRESETS[source], beats=beats) 1433 raise ValueError( 1434 f"Unknown progression preset {source!r} (the preset table ships in a later release). " 1435 "A progression is a list — pass the chords as elements, e.g. progression([1, 6, 3, 7]) " 1436 "or progression(['Am', 'F', 'C', 'G'])." 1437 ) 1438 1439 if source is None: 1440 raise ValueError("progression() needs a source list (or style=...)") 1441 1442 elements = list(source) 1443 1444 if not elements: 1445 raise ValueError("progression list is empty — pass at least one chord") 1446 1447 lengths = _span_lengths(beats, len(elements)) 1448 1449 return Progression(spans = tuple( 1450 parse_element(element, beats=lengths[index]) 1451 for index, element in enumerate(elements) 1452 ))
Build a Progression — the lowercase factory.
Dispatch by argument type: a list parses per element (ints where
diatonic, name/roman strings where nominal/chromatic, (element,
beats) tuples for per-chord durations); a bare string names a
preset from the curated table; style= generates bars chords from a
chord-graph walk (requires key=).
Arguments:
- source: The element list, preset name, or an existing Progression (returned unchanged).
- beats: Span length per chord — a scalar, or a list cycled per chord
(
beats=[4, 4, 2, 6]shapes the harmonic rhythm). - style: A chord-graph style name to generate from (e.g.
"aeolian_minor"). - bars: How many chords to generate (style mode only).
- key: Key for style generation.
- seed: Seed for style generation. A standalone generated value without a seed warns — module-level nondeterminism breaks live reload.
- rng: An explicit random stream (overrides
seed; used by engine-mediated calls). - dominant_7th / gravity / nir_strength: Graph-walk parameters,
matching
Composition.harmony()(style mode only; full pass-through arrives 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)
122@dataclasses.dataclass(frozen=True) 123class Chord: 124 125 """ 126 Represents a chord as a root pitch class and quality. 127 """ 128 129 root_pc: int 130 quality: str 131 132 133 def intervals (self) -> typing.List[int]: 134 135 """ 136 Return the chord intervals for this chord quality. 137 """ 138 139 if self.quality not in CHORD_INTERVALS: 140 raise ValueError(f"Unknown chord quality: {self.quality}") 141 142 return CHORD_INTERVALS[self.quality] 143 144 145 146 def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 147 148 """Return MIDI note numbers for chord tones starting from a root. 149 150 Finds the MIDI note corresponding to the chord's root pitch class that is 151 closest to the provided ``root`` argument. 152 153 Parameters: 154 root: MIDI note number (e.g., 60 = middle C) to center the chord around. 155 inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). 156 Wraps around for values >= number of notes. 157 count: Number of notes to return. When set, the chord intervals cycle 158 into higher octaves until ``count`` notes are produced. When ``None`` 159 (default), returns the natural chord tones. 160 161 Returns: 162 List of MIDI note numbers for chord tones 163 164 Example: 165 ```python 166 chord = Chord(root_pc=0, quality="major") # C major 167 chord.tones(root=60) # [60, 64, 67] - root position around C4 168 chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root 169 chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root 170 ``` 171 """ 172 173 # Find the MIDI note for self.root_pc that is closest to the requested root. 174 # This handles octaves automatically. 175 offset = (self.root_pc - root) % 12 176 if offset > 6: 177 offset -= 12 178 179 effective_root = root + offset 180 181 intervals = self.intervals() 182 183 if inversion != 0: 184 intervals = subsequence.voicings.invert_chord(intervals, inversion) 185 186 if count is not None: 187 n = len(intervals) 188 return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] 189 190 return [effective_root + interval for interval in intervals] 191 192 193 def root_note (self, root_midi: int) -> int: 194 195 """ 196 Return the MIDI note number for the chord root nearest to *root_midi*. 197 198 This is equivalent to ``self.tones(root_midi)[0]`` but makes intent 199 explicit when you only need the single root pitch. 200 201 Parameters: 202 root_midi: Reference MIDI note number used to find the closest octave 203 of this chord's root pitch class. 204 205 Returns: 206 MIDI note number of the chord root. 207 208 Example: 209 ```python 210 chord = Chord(root_pc=4, quality="major") # E major 211 chord.root_note(60) # → 64 (E4, nearest to C4) 212 chord.root_note(69) # → 64 (E4, nearest to A4) 213 ``` 214 """ 215 216 return self.tones(root_midi)[0] 217 218 219 def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: 220 221 """ 222 Return the chord root shifted by a number of octaves. 223 224 Commonly used to produce a bass register note one or two octaves 225 below the chord voicing. 226 227 Parameters: 228 root_midi: Reference MIDI note number (passed to :meth:`root_note`). 229 octave_offset: Octaves to shift; negative moves down (default ``-1``). 230 231 Returns: 232 MIDI note number of the chord root in the target register. 233 234 Example: 235 ```python 236 chord = Chord(root_pc=4, quality="major") # E major 237 chord.bass_note(64) # → 52 (E3, one octave down from E4) 238 chord.bass_note(64, -2) # → 40 (E2, two octaves down) 239 ``` 240 """ 241 242 return self.root_note(root_midi) + (12 * octave_offset) 243 244 245 def name (self) -> str: 246 247 """ 248 Return a human-friendly chord name. 249 250 A registered quality without a suffix prints as ``root(quality)`` 251 (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. 252 """ 253 254 root_name = PC_TO_NOTE_NAME[self.root_pc % 12] 255 256 if self.quality not in CHORD_SUFFIX: 257 return f"{root_name}({self.quality})" 258 259 return f"{root_name}{CHORD_SUFFIX[self.quality]}"
Represents a chord as a root pitch class and quality.
133 def intervals (self) -> typing.List[int]: 134 135 """ 136 Return the chord intervals for this chord quality. 137 """ 138 139 if self.quality not in CHORD_INTERVALS: 140 raise ValueError(f"Unknown chord quality: {self.quality}") 141 142 return CHORD_INTERVALS[self.quality]
Return the chord intervals for this chord quality.
146 def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 147 148 """Return MIDI note numbers for chord tones starting from a root. 149 150 Finds the MIDI note corresponding to the chord's root pitch class that is 151 closest to the provided ``root`` argument. 152 153 Parameters: 154 root: MIDI note number (e.g., 60 = middle C) to center the chord around. 155 inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). 156 Wraps around for values >= number of notes. 157 count: Number of notes to return. When set, the chord intervals cycle 158 into higher octaves until ``count`` notes are produced. When ``None`` 159 (default), returns the natural chord tones. 160 161 Returns: 162 List of MIDI note numbers for chord tones 163 164 Example: 165 ```python 166 chord = Chord(root_pc=0, quality="major") # C major 167 chord.tones(root=60) # [60, 64, 67] - root position around C4 168 chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root 169 chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root 170 ``` 171 """ 172 173 # Find the MIDI note for self.root_pc that is closest to the requested root. 174 # This handles octaves automatically. 175 offset = (self.root_pc - root) % 12 176 if offset > 6: 177 offset -= 12 178 179 effective_root = root + offset 180 181 intervals = self.intervals() 182 183 if inversion != 0: 184 intervals = subsequence.voicings.invert_chord(intervals, inversion) 185 186 if count is not None: 187 n = len(intervals) 188 return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] 189 190 return [effective_root + interval for interval in intervals]
Return MIDI note numbers for chord tones starting from a root.
Finds the MIDI note corresponding to the chord's root pitch class that is
closest to the provided root argument.
Arguments:
- root: MIDI note number (e.g., 60 = middle C) to center the chord around.
- inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). Wraps around for values >= number of notes.
- count: Number of notes to return. When set, the chord intervals cycle
into higher octaves until
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
193 def root_note (self, root_midi: int) -> int: 194 195 """ 196 Return the MIDI note number for the chord root nearest to *root_midi*. 197 198 This is equivalent to ``self.tones(root_midi)[0]`` but makes intent 199 explicit when you only need the single root pitch. 200 201 Parameters: 202 root_midi: Reference MIDI note number used to find the closest octave 203 of this chord's root pitch class. 204 205 Returns: 206 MIDI note number of the chord root. 207 208 Example: 209 ```python 210 chord = Chord(root_pc=4, quality="major") # E major 211 chord.root_note(60) # → 64 (E4, nearest to C4) 212 chord.root_note(69) # → 64 (E4, nearest to A4) 213 ``` 214 """ 215 216 return self.tones(root_midi)[0]
Return the MIDI note number for the chord root nearest to root_midi.
This is equivalent to self.tones(root_midi)[0] but makes intent
explicit when you only need the single root pitch.
Arguments:
- root_midi: Reference MIDI note number used to find the closest octave of this chord's root pitch class.
Returns:
MIDI note number of the chord root.
Example:
chord = Chord(root_pc=4, quality="major") # E major chord.root_note(60) # → 64 (E4, nearest to C4) chord.root_note(69) # → 64 (E4, nearest to A4)
219 def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: 220 221 """ 222 Return the chord root shifted by a number of octaves. 223 224 Commonly used to produce a bass register note one or two octaves 225 below the chord voicing. 226 227 Parameters: 228 root_midi: Reference MIDI note number (passed to :meth:`root_note`). 229 octave_offset: Octaves to shift; negative moves down (default ``-1``). 230 231 Returns: 232 MIDI note number of the chord root in the target register. 233 234 Example: 235 ```python 236 chord = Chord(root_pc=4, quality="major") # E major 237 chord.bass_note(64) # → 52 (E3, one octave down from E4) 238 chord.bass_note(64, -2) # → 40 (E2, two octaves down) 239 ``` 240 """ 241 242 return self.root_note(root_midi) + (12 * octave_offset)
Return the chord root shifted by a number of octaves.
Commonly used to produce a bass register note one or two octaves below the chord voicing.
Arguments:
- root_midi: Reference MIDI note number (passed to
root_note()). - octave_offset: Octaves to shift; negative moves down (default
-1).
Returns:
MIDI note number of the chord root in the target register.
Example:
chord = Chord(root_pc=4, quality="major") # E major chord.bass_note(64) # → 52 (E3, one octave down from E4) chord.bass_note(64, -2) # → 40 (E2, two octaves down)
245 def name (self) -> str: 246 247 """ 248 Return a human-friendly chord name. 249 250 A registered quality without a suffix prints as ``root(quality)`` 251 (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. 252 """ 253 254 root_name = PC_TO_NOTE_NAME[self.root_pc % 12] 255 256 if self.quality not in CHORD_SUFFIX: 257 return f"{root_name}({self.quality})" 258 259 return f"{root_name}{CHORD_SUFFIX[self.quality]}"
Return a human-friendly chord name.
A registered quality without a suffix prints as root(quality)
(e.g. "C(quartal)") rather than masquerading as a plain major.
21@dataclasses.dataclass 22class Groove: 23 24 """ 25 A timing/velocity template applied to quantized grid positions. 26 27 A groove is a repeating pattern of per-step timing offsets and optional 28 velocity adjustments aligned to a rhythmic grid. Apply it as a post-build 29 transform with ``p.groove(template)`` to give a pattern its characteristic 30 feel — swing, shuffle, MPC-style pocket, or anything extracted from an 31 Ableton ``.agr`` file. 32 33 Parameters: 34 offsets: Timing offset per grid slot, in beats. Repeats cyclically. 35 Positive values delay the note; negative values push it earlier. 36 grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). 37 velocities: Optional velocity scale per grid slot (1.0 = unchanged). 38 Repeats cyclically alongside offsets. 39 40 Example:: 41 42 # Ableton-style 57% swing on 16th notes 43 groove = Groove.swing(percent=57) 44 45 # Custom groove with timing and velocity 46 groove = Groove( 47 grid=0.25, 48 offsets=[0.0, +0.02, 0.0, -0.01], 49 velocities=[1.0, 0.7, 0.9, 0.6], 50 ) 51 """ 52 53 offsets: typing.List[float] 54 grid: float = 0.25 55 velocities: typing.Optional[typing.List[float]] = None 56 57 def __post_init__ (self) -> None: 58 if not self.offsets: 59 raise ValueError("offsets must not be empty") 60 if self.grid <= 0: 61 raise ValueError("grid must be positive") 62 if self.velocities is not None and not self.velocities: 63 raise ValueError("velocities must not be empty (use None for no velocity adjustment)") 64 65 @staticmethod 66 def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove": 67 68 """ 69 Create a swing groove from a percentage. 70 71 50% is straight (no swing). 67% is approximately triplet swing. 72 57% is a moderate shuffle — the Ableton default. 73 74 Parameters: 75 percent: Swing amount (50–75 is the useful range). 76 grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths). 77 """ 78 79 if percent < 50.0 or percent > 99.0: 80 raise ValueError("swing percent must be between 50 and 99") 81 pair_duration = grid * 2 82 offset = (percent / 100.0 - 0.5) * pair_duration 83 return Groove(offsets=[0.0, offset], grid=grid) 84 85 @staticmethod 86 def from_agr (path: str) -> "Groove": 87 88 """ 89 Import timing and velocity data from an Ableton .agr groove file. 90 91 An ``.agr`` file is an XML document containing a MIDI clip whose 92 note positions encode the groove's rhythmic feel. This method reads 93 those note start times and velocities and converts them into the 94 ``Groove`` dataclass format (per-step offsets and velocity scales). 95 96 **What is extracted:** 97 98 - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets 99 relative to ideal grid positions. 100 - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity 101 scaling (normalised to the highest velocity in the file). 102 - ``TimingAmount`` from the Groove element → pre-scales the timing 103 offsets (100 = full, 70 = 70% of the groove's timing). 104 - ``VelocityAmount`` from the Groove element → pre-scales velocity 105 deviation (100 = full groove velocity, 0 = no velocity changes). 106 107 The resulting ``Groove`` reflects the file author's intended 108 strength. Use ``strength=`` when applying to further adjust. 109 110 **What is NOT imported:** 111 112 ``RandomAmount`` (use ``p.randomize()`` separately for random 113 jitter) and ``QuantizationAmount`` (not applicable - Subsequence 114 notes are already grid-quantized by construction). 115 116 Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, 117 ``OffVelocity``, ``Probability``) are also ignored. 118 119 Parameters: 120 path: Path to the .agr file. 121 """ 122 123 tree = xml.etree.ElementTree.parse(path) 124 root = tree.getroot() 125 126 # Find the MIDI clip 127 clip = root.find(".//MidiClip") 128 if clip is None: 129 raise ValueError(f"No MidiClip found in {path}") 130 131 # Get clip length 132 current_end = clip.find("CurrentEnd") 133 if current_end is None: 134 raise ValueError(f"No CurrentEnd found in {path}") 135 clip_length = float(current_end.get("Value", "4")) 136 137 # Read Groove Pool blend parameters 138 groove_elem = root.find(".//Groove") 139 timing_amount = 100.0 140 velocity_amount = 100.0 141 if groove_elem is not None: 142 timing_el = groove_elem.find("TimingAmount") 143 if timing_el is not None: 144 timing_amount = float(timing_el.get("Value", "100")) 145 velocity_el = groove_elem.find("VelocityAmount") 146 if velocity_el is not None: 147 velocity_amount = float(velocity_el.get("Value", "100")) 148 149 timing_scale = timing_amount / 100.0 150 velocity_scale = velocity_amount / 100.0 151 152 # Extract note events sorted by time 153 events = clip.findall(".//MidiNoteEvent") 154 if not events: 155 raise ValueError(f"No MidiNoteEvent elements found in {path}") 156 157 times: typing.List[float] = [] 158 velocities_raw: typing.List[float] = [] 159 for event in events: 160 times.append(float(event.get("Time", "0"))) 161 velocities_raw.append(float(event.get("Velocity", "127"))) 162 163 # Sort as PAIRS - sorting times alone desynced each offset from its 164 # note's velocity whenever the XML listed events out of time order. 165 paired = sorted(zip(times, velocities_raw)) 166 times = [t for t, _ in paired] 167 velocities_raw = [v for _, v in paired] 168 169 note_count = len(times) 170 171 # Infer grid from clip length and note count 172 grid = clip_length / note_count 173 174 # Calculate offsets from ideal grid positions, scaled by TimingAmount 175 offsets: typing.List[float] = [] 176 for i, time in enumerate(times): 177 ideal = i * grid 178 offsets.append((time - ideal) * timing_scale) 179 180 # Calculate velocity scales (relative to max velocity in the file), 181 # blended toward 1.0 by VelocityAmount 182 max_vel = max(velocities_raw) 183 has_velocity_variation = any(v != max_vel for v in velocities_raw) 184 groove_velocities: typing.Optional[typing.List[float]] = None 185 if has_velocity_variation and max_vel > 0: 186 raw_scales = [v / max_vel for v in velocities_raw] 187 # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) 188 groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] 189 # If blending has removed all variation, set to None 190 if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): 191 groove_velocities = None 192 193 return Groove(offsets=offsets, grid=grid, velocities=groove_velocities)
A timing/velocity template applied to quantized grid positions.
A groove is a repeating pattern of per-step timing offsets and optional
velocity adjustments aligned to a rhythmic grid. Apply it as a post-build
transform with p.groove(template) to give a pattern its characteristic
feel — swing, shuffle, MPC-style pocket, or anything extracted from an
Ableton .agr file.
Arguments:
- offsets: Timing offset per grid slot, in beats. Repeats cyclically. Positive values delay the note; negative values push it earlier.
- grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes).
- velocities: Optional velocity scale per grid slot (1.0 = unchanged). Repeats cyclically alongside offsets.
Example::
# Ableton-style 57% swing on 16th notes
groove = Groove.swing(percent=57)
# Custom groove with timing and velocity
groove = Groove(
grid=0.25,
offsets=[0.0, +0.02, 0.0, -0.01],
velocities=[1.0, 0.7, 0.9, 0.6],
)
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) -> "Groove": 87 88 """ 89 Import timing and velocity data from an Ableton .agr groove file. 90 91 An ``.agr`` file is an XML document containing a MIDI clip whose 92 note positions encode the groove's rhythmic feel. This method reads 93 those note start times and velocities and converts them into the 94 ``Groove`` dataclass format (per-step offsets and velocity scales). 95 96 **What is extracted:** 97 98 - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets 99 relative to ideal grid positions. 100 - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity 101 scaling (normalised to the highest velocity in the file). 102 - ``TimingAmount`` from the Groove element → pre-scales the timing 103 offsets (100 = full, 70 = 70% of the groove's timing). 104 - ``VelocityAmount`` from the Groove element → pre-scales velocity 105 deviation (100 = full groove velocity, 0 = no velocity changes). 106 107 The resulting ``Groove`` reflects the file author's intended 108 strength. Use ``strength=`` when applying to further adjust. 109 110 **What is NOT imported:** 111 112 ``RandomAmount`` (use ``p.randomize()`` separately for random 113 jitter) and ``QuantizationAmount`` (not applicable - Subsequence 114 notes are already grid-quantized by construction). 115 116 Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, 117 ``OffVelocity``, ``Probability``) are also ignored. 118 119 Parameters: 120 path: Path to the .agr file. 121 """ 122 123 tree = xml.etree.ElementTree.parse(path) 124 root = tree.getroot() 125 126 # Find the MIDI clip 127 clip = root.find(".//MidiClip") 128 if clip is None: 129 raise ValueError(f"No MidiClip found in {path}") 130 131 # Get clip length 132 current_end = clip.find("CurrentEnd") 133 if current_end is None: 134 raise ValueError(f"No CurrentEnd found in {path}") 135 clip_length = float(current_end.get("Value", "4")) 136 137 # Read Groove Pool blend parameters 138 groove_elem = root.find(".//Groove") 139 timing_amount = 100.0 140 velocity_amount = 100.0 141 if groove_elem is not None: 142 timing_el = groove_elem.find("TimingAmount") 143 if timing_el is not None: 144 timing_amount = float(timing_el.get("Value", "100")) 145 velocity_el = groove_elem.find("VelocityAmount") 146 if velocity_el is not None: 147 velocity_amount = float(velocity_el.get("Value", "100")) 148 149 timing_scale = timing_amount / 100.0 150 velocity_scale = velocity_amount / 100.0 151 152 # Extract note events sorted by time 153 events = clip.findall(".//MidiNoteEvent") 154 if not events: 155 raise ValueError(f"No MidiNoteEvent elements found in {path}") 156 157 times: typing.List[float] = [] 158 velocities_raw: typing.List[float] = [] 159 for event in events: 160 times.append(float(event.get("Time", "0"))) 161 velocities_raw.append(float(event.get("Velocity", "127"))) 162 163 # Sort as PAIRS - sorting times alone desynced each offset from its 164 # note's velocity whenever the XML listed events out of time order. 165 paired = sorted(zip(times, velocities_raw)) 166 times = [t for t, _ in paired] 167 velocities_raw = [v for _, v in paired] 168 169 note_count = len(times) 170 171 # Infer grid from clip length and note count 172 grid = clip_length / note_count 173 174 # Calculate offsets from ideal grid positions, scaled by TimingAmount 175 offsets: typing.List[float] = [] 176 for i, time in enumerate(times): 177 ideal = i * grid 178 offsets.append((time - ideal) * timing_scale) 179 180 # Calculate velocity scales (relative to max velocity in the file), 181 # blended toward 1.0 by VelocityAmount 182 max_vel = max(velocities_raw) 183 has_velocity_variation = any(v != max_vel for v in velocities_raw) 184 groove_velocities: typing.Optional[typing.List[float]] = None 185 if has_velocity_variation and max_vel > 0: 186 raw_scales = [v / max_vel for v in velocities_raw] 187 # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) 188 groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] 189 # If blending has removed all variation, set to None 190 if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): 191 groove_velocities = None 192 193 return Groove(offsets=offsets, grid=grid, velocities=groove_velocities)
Import timing and velocity data from an Ableton .agr groove file.
An .agr file is an XML document containing a MIDI clip whose
note positions encode the groove's rhythmic feel. This method reads
those note start times and velocities and converts them into the
Groove dataclass format (per-step offsets and velocity scales).
What is extracted:
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.
24class MelodicState: 25 26 """Persistent melodic context that applies NIR scoring to single-note lines.""" 27 28 29 def __init__ ( 30 self, 31 key: str = "C", 32 mode: str = "ionian", 33 low: int = 48, 34 high: int = 72, 35 nir_strength: float = 0.5, 36 chord_weight: float = 0.4, 37 rest_probability: float = 0.0, 38 pitch_diversity: float = 0.6, 39 ) -> None: 40 41 """Initialise a melodic state for a given key, mode, and MIDI register. 42 43 Parameters: 44 key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). 45 mode: Scale mode name. Accepts any mode registered with 46 :func:`~subsequence.intervals.scale_pitch_classes` (e.g. 47 ``"ionian"``, ``"aeolian"``, ``"dorian"``). 48 low: Lowest MIDI note (inclusive) in the pitch pool. 49 high: Highest MIDI note (inclusive) in the pitch pool. 50 nir_strength: 0.0–1.0. Scales how strongly the NIR rules 51 influence candidate scores. 0.0 = uniform; 1.0 = full boost. 52 chord_weight: 0.0–1.0. Additive multiplier bonus for candidates 53 whose pitch class belongs to the current chord tones. 54 rest_probability: 0.0–1.0. Probability of producing a rest 55 (returning ``None``) at any given step. 56 pitch_diversity: 0.0–1.0. Exponential penalty per recent 57 repetition of the same pitch. Lower values discourage 58 repetition more aggressively. 59 """ 60 61 if nir_strength < 0 or nir_strength > 1: 62 raise ValueError("NIR strength must be between 0 and 1") 63 64 if rest_probability < 0 or rest_probability > 1: 65 raise ValueError("Rest probability must be between 0 and 1") 66 67 if pitch_diversity < 0 or pitch_diversity > 1: 68 raise ValueError("Pitch diversity must be between 0 and 1") 69 70 if chord_weight < 0: 71 raise ValueError("Chord weight must be non-negative") 72 73 if low >= high: 74 raise ValueError("low must be below high") 75 76 self.key = key 77 self.mode = mode 78 self.low = low 79 self.high = high 80 self.nir_strength = nir_strength 81 self.chord_weight = chord_weight 82 self.rest_probability = rest_probability 83 self.pitch_diversity = pitch_diversity 84 85 key_pc = subsequence.chords.key_name_to_pc(key) 86 87 # Pitch pool: all scale tones within [low, high]. 88 self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes( 89 key, mode, low=low, high=high 90 ) 91 92 # Tonic pitch class for Rule C (closure). 93 self._tonic_pc: int = key_pc 94 95 # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). 96 self.history: typing.List[int] = [] 97 98 99 def choose_next ( 100 self, 101 chord_tones: typing.Optional[typing.List[int]], 102 rng: random.Random, 103 ) -> typing.Optional[int]: 104 105 """Score all pitch-pool candidates and return the chosen pitch, or None for a rest.""" 106 107 if self.rest_probability > 0.0 and rng.random() < self.rest_probability: 108 return None 109 110 if not self._pitch_pool: 111 return None 112 113 # Resolve chord tones to pitch classes for fast membership testing. 114 chord_tone_pcs: typing.Set[int] = ( 115 {t % 12 for t in chord_tones} if chord_tones else set() 116 ) 117 118 scores = [self._score_candidate(p, chord_tone_pcs) for p in self._pitch_pool] 119 120 # Weighted random choice: select using cumulative score as a probability weight. 121 total = sum(scores) 122 123 if total <= 0.0: 124 chosen = rng.choice(self._pitch_pool) 125 126 else: 127 r = rng.uniform(0.0, total) 128 cumulative = 0.0 129 chosen = self._pitch_pool[-1] 130 131 for pitch, score in zip(self._pitch_pool, scores): 132 cumulative += score 133 if r <= cumulative: 134 chosen = pitch 135 break 136 137 # Persist history for the next call (capped at 4 entries). 138 self.history.append(chosen) 139 if len(self.history) > 4: 140 self.history.pop(0) 141 142 return chosen 143 144 145 def _score_candidate ( 146 self, 147 candidate: int, 148 chord_tone_pcs: typing.Set[int], 149 ) -> float: 150 151 """Score one candidate pitch using NIR rules, chord weighting, range gravity, and pitch diversity.""" 152 153 score = 1.0 154 155 # --- NIR rules (require at least one history note for Realization) --- 156 if self.history: 157 last_note = self.history[-1] 158 159 target_diff = candidate - last_note 160 target_interval = abs(target_diff) 161 target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0 162 163 # Rules A & B require an Implication context (prev -> last -> candidate). 164 if len(self.history) >= 2: 165 prev_note = self.history[-2] 166 167 prev_diff = last_note - prev_note 168 prev_interval = abs(prev_diff) 169 prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0 170 171 # Rule A: Reversal (gap fill) — after a large leap, expect direction change. 172 if prev_interval > 4: 173 if target_direction != prev_direction and target_direction != 0: 174 score += 0.5 175 176 if target_interval < 4: 177 score += 0.3 178 179 # Rule B: Process (continuation) — after a small step, expect more of the same. 180 elif 0 < prev_interval < 3: 181 if target_direction == prev_direction: 182 score += 0.4 183 184 if abs(target_interval - prev_interval) <= 1: 185 score += 0.2 186 187 # Rule C: Closure — the tonic is a cognitively stable landing point. 188 if candidate % 12 == self._tonic_pc: 189 score += 0.2 190 191 # Rule D: Proximity — smaller intervals are generally preferred. 192 if 0 < target_interval <= 3: 193 score += 0.3 194 195 # Scale the entire NIR boost by nir_strength, leaving the base at 1.0. 196 score = 1.0 + (score - 1.0) * self.nir_strength 197 198 # --- Chord tone boost --- 199 if candidate % 12 in chord_tone_pcs: 200 score *= 1.0 + self.chord_weight 201 202 # --- Range gravity: penalise notes far from the centre of [low, high] --- 203 centre = (self.low + self.high) / 2.0 204 half_range = max(1.0, (self.high - self.low) / 2.0) 205 distance_ratio = abs(candidate - centre) / half_range 206 score *= 1.0 - 0.3 * (distance_ratio ** 2) 207 208 # --- Pitch diversity: exponential penalty for recently-heard pitches --- 209 recent_occurrences = sum(1 for h in self.history if h == candidate) 210 score *= self.pitch_diversity ** recent_occurrences 211 212 return max(0.0, score)
Persistent melodic context that applies NIR scoring to single-note lines.
29 def __init__ ( 30 self, 31 key: str = "C", 32 mode: str = "ionian", 33 low: int = 48, 34 high: int = 72, 35 nir_strength: float = 0.5, 36 chord_weight: float = 0.4, 37 rest_probability: float = 0.0, 38 pitch_diversity: float = 0.6, 39 ) -> None: 40 41 """Initialise a melodic state for a given key, mode, and MIDI register. 42 43 Parameters: 44 key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). 45 mode: Scale mode name. Accepts any mode registered with 46 :func:`~subsequence.intervals.scale_pitch_classes` (e.g. 47 ``"ionian"``, ``"aeolian"``, ``"dorian"``). 48 low: Lowest MIDI note (inclusive) in the pitch pool. 49 high: Highest MIDI note (inclusive) in the pitch pool. 50 nir_strength: 0.0–1.0. Scales how strongly the NIR rules 51 influence candidate scores. 0.0 = uniform; 1.0 = full boost. 52 chord_weight: 0.0–1.0. Additive multiplier bonus for candidates 53 whose pitch class belongs to the current chord tones. 54 rest_probability: 0.0–1.0. Probability of producing a rest 55 (returning ``None``) at any given step. 56 pitch_diversity: 0.0–1.0. Exponential penalty per recent 57 repetition of the same pitch. Lower values discourage 58 repetition more aggressively. 59 """ 60 61 if nir_strength < 0 or nir_strength > 1: 62 raise ValueError("NIR strength must be between 0 and 1") 63 64 if rest_probability < 0 or rest_probability > 1: 65 raise ValueError("Rest probability must be between 0 and 1") 66 67 if pitch_diversity < 0 or pitch_diversity > 1: 68 raise ValueError("Pitch diversity must be between 0 and 1") 69 70 if chord_weight < 0: 71 raise ValueError("Chord weight must be non-negative") 72 73 if low >= high: 74 raise ValueError("low must be below high") 75 76 self.key = key 77 self.mode = mode 78 self.low = low 79 self.high = high 80 self.nir_strength = nir_strength 81 self.chord_weight = chord_weight 82 self.rest_probability = rest_probability 83 self.pitch_diversity = pitch_diversity 84 85 key_pc = subsequence.chords.key_name_to_pc(key) 86 87 # Pitch pool: all scale tones within [low, high]. 88 self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes( 89 key, mode, low=low, high=high 90 ) 91 92 # Tonic pitch class for Rule C (closure). 93 self._tonic_pc: int = key_pc 94 95 # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). 96 self.history: typing.List[int] = []
Initialise a melodic state for a given key, mode, and MIDI register.
Arguments:
- key: Root note of the key (e.g.
"C","F#","Bb"). - mode: Scale mode name. Accepts any mode registered with
~subsequence.intervals.scale_pitch_classes()(e.g."ionian","aeolian","dorian"). - low: Lowest MIDI note (inclusive) in the pitch pool.
- high: Highest MIDI note (inclusive) in the pitch pool.
- nir_strength: 0.0–1.0. Scales how strongly the NIR rules influence candidate scores. 0.0 = uniform; 1.0 = full boost.
- chord_weight: 0.0–1.0. Additive multiplier bonus for candidates whose pitch class belongs to the current chord tones.
- rest_probability: 0.0–1.0. Probability of producing a rest
(returning
None) at any given step. - pitch_diversity: 0.0–1.0. Exponential penalty per recent repetition of the same pitch. Lower values discourage repetition more aggressively.
99 def choose_next ( 100 self, 101 chord_tones: typing.Optional[typing.List[int]], 102 rng: random.Random, 103 ) -> typing.Optional[int]: 104 105 """Score all pitch-pool candidates and return the chosen pitch, or None for a rest.""" 106 107 if self.rest_probability > 0.0 and rng.random() < self.rest_probability: 108 return None 109 110 if not self._pitch_pool: 111 return None 112 113 # Resolve chord tones to pitch classes for fast membership testing. 114 chord_tone_pcs: typing.Set[int] = ( 115 {t % 12 for t in chord_tones} if chord_tones else set() 116 ) 117 118 scores = [self._score_candidate(p, chord_tone_pcs) for p in self._pitch_pool] 119 120 # Weighted random choice: select using cumulative score as a probability weight. 121 total = sum(scores) 122 123 if total <= 0.0: 124 chosen = rng.choice(self._pitch_pool) 125 126 else: 127 r = rng.uniform(0.0, total) 128 cumulative = 0.0 129 chosen = self._pitch_pool[-1] 130 131 for pitch, score in zip(self._pitch_pool, scores): 132 cumulative += score 133 if r <= cumulative: 134 chosen = pitch 135 break 136 137 # Persist history for the next call (capped at 4 entries). 138 self.history.append(chosen) 139 if len(self.history) > 4: 140 self.history.pop(0) 141 142 return chosen
Score all pitch-pool candidates and return the chosen pitch, or None for a rest.
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."
383def parse_chord (name: str) -> Chord: 384 385 """Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`. 386 387 The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed 388 by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished, 389 ``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor 390 7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``. A few common 391 alternates (``min``, ``-``, ``M7``, …) are accepted too. 392 393 Raises ``ValueError`` for anything it can't read, so a typo surfaces at the 394 call site rather than as a silently wrong chord. 395 396 Example: 397 ```python 398 parse_chord("Cm7") # → Chord(root_pc=0, quality="minor_7th") 399 parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th") 400 parse_chord("F#") # → Chord(root_pc=6, quality="major") 401 ``` 402 """ 403 404 stripped = name.strip() 405 if not stripped or stripped[0] not in "ABCDEFG": 406 raise ValueError(f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'") 407 408 split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1 409 root_name = stripped[:split] 410 suffix = stripped[split:] 411 412 if root_name not in NOTE_NAME_TO_PC: 413 raise ValueError(f"Cannot parse chord name {name!r} — unknown root {root_name!r}") 414 if suffix not in _SUFFIX_TO_QUALITY: 415 known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key) 416 raise ValueError(f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}") 417 418 return Chord(root_pc=NOTE_NAME_TO_PC[root_name], quality=_SUFFIX_TO_QUALITY[suffix])
Parse a chord name like "Cm7" or "Dbmaj7" into a Chord.
The name is a root note (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")
302def register_chord_quality ( 303 name: str, 304 intervals: typing.List[int], 305 suffix: typing.Optional[str] = None, 306) -> None: 307 308 """Register a custom chord quality for use everywhere chords are used. 309 310 The counterpart to :func:`subsequence.intervals.register_scale` — it opens 311 the quality table so quartal stacks, clusters, and extended chords become 312 first-class symbolic chords: they work in progressions, graphs, voice 313 leading, and ``describe()`` output. 314 315 Built-in qualities (e.g. ``"minor"``) cannot be overwritten. Custom names 316 may be re-registered freely — live reload re-runs registration on every 317 save, so this must not raise. 318 319 Parameters: 320 name: Quality name (used as ``Chord(root_pc, quality=name)``). 321 intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a 322 quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th). Must start 323 with 0, ascend strictly, and stay within 0–24 (extensions reach 324 past the octave). 325 suffix: Optional chord-name suffix. When given, ``parse_chord()`` 326 accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so 327 ``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")`` 328 makes ``"Am9"`` parse from then on. Must not collide with a 329 built-in suffix. 330 331 Example: 332 ```python 333 import subsequence 334 335 subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4") 336 subsequence.parse_chord("Dq4") # → Chord(root_pc=2, quality="quartal") 337 ``` 338 """ 339 340 if name in _BUILTIN_QUALITY_NAMES: 341 raise ValueError( 342 f"Cannot overwrite built-in chord quality '{name}'. " 343 "Choose a different name for your custom quality." 344 ) 345 346 if not intervals: 347 raise ValueError("intervals must not be empty") 348 if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals): 349 raise ValueError("intervals must be whole numbers (semitone offsets)") 350 if intervals[0] != 0: 351 raise ValueError("intervals must start with 0 (the root)") 352 if any(b <= a for a, b in zip(intervals, intervals[1:])): 353 raise ValueError("intervals must be strictly ascending") 354 if any(i < 0 or i > 24 for i in intervals): 355 raise ValueError("intervals must contain values between 0 and 24") 356 357 if suffix is not None: 358 if suffix in _BUILTIN_SUFFIXES: 359 raise ValueError( 360 f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. " 361 "Choose a different suffix for your custom quality." 362 ) 363 if not suffix or suffix[0] in "ABCDEFG#b0123456789": 364 raise ValueError( 365 f"Suffix {suffix!r} would be ambiguous in a chord name — " 366 "it must not be empty or start with a note letter, accidental, or digit" 367 ) 368 369 # Re-registration: drop any suffix this quality registered previously, so 370 # renaming a suffix on live reload does not leave a stale alias behind. 371 for old_suffix in [s for s, q in _SUFFIX_TO_QUALITY.items() if q == name and s not in _BUILTIN_SUFFIXES]: 372 del _SUFFIX_TO_QUALITY[old_suffix] 373 374 CHORD_INTERVALS[name] = list(intervals) 375 376 if suffix is not None: 377 CHORD_SUFFIX[name] = suffix 378 _SUFFIX_TO_QUALITY[suffix] = name 379 else: 380 CHORD_SUFFIX.pop(name, None)
Register a custom chord quality for use everywhere chords are used.
The counterpart to subsequence.intervals.register_scale() — it opens
the quality table so quartal stacks, clusters, and extended chords become
first-class symbolic chords: they work in progressions, graphs, voice
leading, and describe() output.
Built-in qualities (e.g. "minor") cannot be overwritten. Custom names
may be re-registered freely — live reload re-runs registration on every
save, so this must not raise.
Arguments:
- name: Quality name (used as
Chord(root_pc, quality=name)). - intervals: Semitone offsets from the root (e.g.
[0, 5, 10]for a quartal stack,[0, 3, 7, 10, 14]for a minor 9th). Must start with 0, ascend strictly, and stay within 0–24 (extensions reach past the octave). - suffix: Optional chord-name suffix. When given,
parse_chord()accepts"A" + 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")
333def register_scale ( 334 name: str, 335 intervals: typing.List[int], 336 qualities: typing.Optional[typing.List[str]] = None 337) -> None: 338 339 """ 340 Register a custom scale for use with ``p.snap_to_scale()`` and 341 ``scale_pitch_classes()``. 342 343 Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be 344 overwritten. Custom names may be re-registered freely — live reload 345 re-runs registration on every save, so this must not raise. 346 347 Parameters: 348 name: Scale name (used in ``p.snap_to_scale(key, name)``). Must not 349 be the name of a built-in scale. 350 intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]`` 351 for Hirajōshi). Must be whole numbers, start with 0, ascend 352 strictly, and stay within 0–11. 353 qualities: Optional chord quality per scale degree (e.g. 354 ``["minor", "major", "minor", "major", "diminished"]``). 355 Required only if you want to use the scale with 356 ``diatonic_chords()`` or ``diatonic_chord_sequence()``. 357 358 Raises: 359 ValueError: If *name* is a built-in scale, or *intervals* / 360 *qualities* fail the rules above. 361 362 Example:: 363 364 import subsequence 365 366 subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11]) 367 368 @comp.pattern(channel=0, length=4) 369 def melody (p): 370 p.note(60, beat=0) 371 p.snap_to_scale("C", "raga_bhairav") 372 """ 373 374 if name in _BUILTIN_SCALE_NAMES: 375 raise ValueError( 376 f"Cannot overwrite built-in scale '{name}'. " 377 "Choose a different name for your custom scale." 378 ) 379 380 if not intervals: 381 raise ValueError("intervals must not be empty") 382 if not all(isinstance(i, int) for i in intervals): 383 raise ValueError("intervals must be whole numbers (semitone offsets)") 384 if intervals[0] != 0: 385 raise ValueError("intervals must start with 0") 386 if any(b <= a for a, b in zip(intervals, intervals[1:])): 387 raise ValueError("intervals must be strictly ascending") 388 if any(i < 0 or i > 11 for i in intervals): 389 raise ValueError("intervals must contain values between 0 and 11") 390 if qualities is not None and len(qualities) != len(intervals): 391 raise ValueError( 392 f"qualities length ({len(qualities)}) must match " 393 f"intervals length ({len(intervals)})" 394 ) 395 396 INTERVAL_DEFINITIONS[name] = intervals 397 SCALE_MODE_MAP[name] = (name, qualities)
Register a custom scale for use with p.snap_to_scale() and
scale_pitch_classes().
Built-in scale names (e.g. "minor", "hirajoshi") cannot be
overwritten. Custom names may be re-registered freely — live reload
re-runs registration on every save, so this must not raise.
Arguments:
- name: Scale name (used in
p.snap_to_scale(key, name)). Must not be the name of a built-in scale. - intervals: Semitone offsets from the root (e.g.
[0, 2, 3, 7, 8]for Hirajōshi). Must be whole numbers, start with 0, ascend strictly, and stay within 0–11. - qualities: Optional chord quality per scale degree (e.g.
["minor", "major", "minor", "major", "diminished"]). Required only if you want to use the scale 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")
189def scale_notes ( 190 key: str, 191 mode: str = "ionian", 192 low: int = 60, 193 high: int = 72, 194 count: typing.Optional[int] = None, 195) -> typing.List[int]: 196 197 """Return MIDI note numbers for a scale within a pitch range. 198 199 Parameters: 200 key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.). 201 This acts as a **pitch-class filter only** — it determines which 202 semitone positions (0–11) are valid members of the scale, but does 203 not affect which octave notes are drawn from. Notes are selected 204 starting from ``low`` upward; ``key`` controls *which* notes are 205 kept, not where the sequence starts. To guarantee the first 206 returned note is the root, ``low`` must be a MIDI number whose 207 pitch class matches ``key``. When starting from an arbitrary MIDI 208 number, derive the key name with 209 ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``. 210 mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP` 211 (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``, 212 ``"major_pentatonic"``). Use :func:`register_scale` for custom scales. 213 low: Lowest MIDI note (inclusive). When ``count`` is set, this is 214 the starting note from which the scale ascends. **If ``low`` is 215 not a member of the scale defined by ``key``, it is silently 216 skipped** and the first returned note will be the next in-scale 217 pitch above ``low``. 218 high: Highest MIDI note (inclusive). Ignored when ``count`` is set. 219 count: Exact number of notes to return. Notes ascend from ``low`` 220 through successive scale degrees, cycling into higher octaves 221 as needed. When ``None`` (default), all scale tones between 222 ``low`` and ``high`` are returned. 223 224 Returns: 225 Sorted list of MIDI note numbers. 226 227 Examples: 228 ```python 229 import subsequence 230 import subsequence.constants.midi_notes as notes 231 232 # C major: all tones from middle C to C5 233 subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5) 234 # → [60, 62, 64, 65, 67, 69, 71, 72] 235 236 # E natural minor (aeolian) across one octave 237 subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3) 238 # → [40, 42, 43, 45, 47, 48, 50, 52] 239 240 # 15 notes of A minor pentatonic ascending from A3 241 subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15) 242 # → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91] 243 244 # Misalignment: key="E" but low=C4 — first note is C, not E 245 subsequence.scale_notes("E", "minor", low=60, count=4) 246 # → [60, 62, 64, 66] (C D E F# — all in E natural minor, but starts on C) 247 248 # Fix: derive key name from root_pitch so low is always in the scale 249 root_pitch = 64 # E4 250 key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12] # → "E" 251 subsequence.scale_notes(key, "minor", low=root_pitch, count=4) 252 # → [64, 66, 67, 69] (E F# G A — starts on the root) 253 ``` 254 """ 255 256 key_pc = subsequence.chords.key_name_to_pc(key) 257 pcs = set(scale_pitch_classes(key_pc, mode)) 258 259 if count is not None: 260 if not pcs: 261 return [] 262 result: typing.List[int] = [] 263 pitch = low 264 while len(result) < count and pitch <= 127: 265 if pitch % 12 in pcs: 266 result.append(pitch) 267 pitch += 1 268 return result 269 270 return [p for p in range(low, high + 1) if p % 12 in pcs]
Return MIDI note numbers for a scale within a pitch range.
Arguments:
- key: Scale root as a note name (
"C","F#","Bb", etc.). This acts as a pitch-class filter only — it determines which semitone positions (0–11) are valid members of the scale, but does not affect which octave notes are drawn from. Notes are selected starting 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)
178def bank_select (bank: int) -> typing.Tuple[int, int]: 179 180 """ 181 Convert a 14-bit MIDI bank number to (MSB, LSB) for use with 182 ``p.program_change()``. 183 184 MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and 185 CC 32 (Bank LSB). Together they encode a 14-bit bank number in the 186 range 0–16,383: 187 188 MSB = bank // 128 (upper 7 bits, sent on CC 0) 189 LSB = bank % 128 (lower 7 bits, sent on CC 32) 190 191 Args: 192 bank: Integer bank number, 0–16,383. Values outside this range are 193 clamped. 194 195 Returns: 196 ``(msb, lsb)`` tuple, each value in 0–127. 197 198 Example: 199 ```python 200 msb, lsb = subsequence.bank_select(128) # → (1, 0) 201 p.program_change(48, bank_msb=msb, bank_lsb=lsb) 202 ``` 203 """ 204 205 bank = max(0, min(16383, bank)) 206 return bank >> 7, bank & 0x7F
Convert a 14-bit MIDI bank number to (MSB, LSB) for use with
p.program_change().
MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and CC 32 (Bank LSB). Together they encode a 14-bit bank number in the range 0–16,383:
MSB = bank // 128 (upper 7 bits, sent on CC 0)
LSB = bank % 128 (lower 7 bits, sent on CC 32)
Arguments:
- bank: Integer bank number, 0–16,383. Values outside this range are clamped.
Returns:
(msb, lsb)tuple, each value in 0–127.
Example:
msb, lsb = subsequence.bank_select(128) # → (1, 0) p.program_change(48, bank_msb=msb, bank_lsb=lsb)