Chapter 12 · Deep Generative Systems: Chaos, Automata, and Scored Melody¶
In Chapter 11 you learned to pin a whole piece to one seed, lock the parts you like, and freeze a take into something you can ship. Now we open the deep end of the generator cabinet: rhythms grown from cellular automata and L-systems, melodies walked by Markov chains and strange attractors, densities drifting on noise fields, lines scored by a cognitive melody model, and a toolkit for reshaping any of it after the fact. Everything here is wild on purpose — and everything here stays reproducible, because the seed habit from Chapter 11 tames it.
These are not new kinds of object. Every generator in this chapter is just another
verb on the builder p, placing ordinary notes onto the same step grid you met in
Chapter 1, resolving against the same chord (Chapter
6) and section (Chapter 10) as everything
else. What is new is where the notes come from: instead of a list you wrote, a
rule unfolds them. Your job shifts from placing notes to choosing and steering a
process — picking the rule, seeding it, and shaping its output with the
transforms at the end of the chapter.
See also
Curious where these ideas come from? Euclidean rhythms, cellular automata, L-systems, Markov chains, Perlin noise, strange attractors and the rest each have a history in maths, physics and biology — collected in Appendix F: A Field Guide to the Generators.
Important
Wild generators need a seed, every time. A cellular automaton or a Markov walk
makes fresh random choices unless you pin them. Without a seed= on the
Composition (or on the call), the music is different on every render and on every
live reload — which breaks the explore-then-capture workflow of Chapter
11. Every example below seeds the Composition; carry that
habit into your own pieces. Where a generator takes its own seed=/rng=, those
follow the precedence rules you already learned.
12.1 Cellular automata (cellular_1d / cellular_2d)¶
A cellular automaton (CA) is a row of cells, each on or off, that updates every generation by a fixed rule looking only at its neighbours. From those trivial local rules grow patterns of startling complexity — Stephen Wolfram catalogued all 256 one-dimensional rules in the 1980s and found that Rule 30 produces output indistinguishable from randomness while Rule 90 draws a perfect fractal. For us, each generation is a bar of rhythm, and the generation advances by one every cycle — so the pattern evolves on its own, the rebuild loop from Chapter 2 feeding it forward.
p.cellular_1d(pitch, rule, generation, ...) maps one row of cells to one drum
voice: a live cell is a hit. Here Rule 90 grows a self-organising hi-hat over a
hand-placed kick, evolving a little every bar:
import subsequence
import subsequence.constants.instruments.gm_drums as gm_drums
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=42)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
p.hit_steps("kick_1", [0, 8], velocity=110) # the floor, by hand
# Rule 90 draws a fractal hi-hat that changes generation by generation.
p.cellular_1d("hi_hat_closed", rule=90, velocity=(40, 70), no_overlap=True)
composition.render(bars=4, filename="cellular-1d.mid")
The generation defaults to p.cycle, so you never set it — the CA steps forward
one bar at a time automatically. no_overlap=True keeps the CA hats from doubling a
hit you placed by hand. The (40, 70) velocity tuple draws a fresh value per hit,
exactly the humanising range from §1.5.
Tip
Rules are personalities, not parameters to sweep blindly. Rule 30 is quasi-random (a good shaker or ghost layer); Rule 90 is fractal and symmetric (a self-similar hat); Rule 110 is Turing-complete and produces drifting, structured activity. Pick a rule for the character you want and leave it — the evolution across generations gives you the variation, not a rule change.
p.cellular_2d(pitches, rule, ...) runs a Life-like 2D automaton: rows map to
instruments, columns to time steps, and a live cell in the final generation is a
note. One call gives you a whole interlocking kit that breathes from bar to bar:
composition = subsequence.Composition(bpm=120, seed=7)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def kit(p):
# Four rows → four voices; Conway's Life from a seeded random start.
p.cellular_2d(
["kick_1", "snare_1", "hi_hat_closed", "hi_hat_open"],
rule="B3/S23", # Conway's Life (B368/S245 = busier "Morley")
initial_state="random", seed=7, density=0.3,
velocity=[100, 90, 70, 60], # one velocity per row
)
composition.render(bars=4, filename="cellular-2d.mid")
The rule here is birth/survival notation: "B3/S23" is Conway’s classic Life
(a dead cell is born with exactly 3 live neighbours; a live cell survives with 2 or
3). initial_state="random" with a seed= and density= gives a reproducible
starting grid; "center" (the default) lights a single middle cell for a pattern
that radiates outward.
Warning
seed= on cellular_2d only seeds the "random" start. With
initial_state="center" or an explicit grid, the start is already determined, so a
seed= there does nothing and Subsequence warns you. Seed the grid you can seed;
for the others the determinism is built in.
Reference
12.2 L-systems and Markov chains¶
Two more rule-driven generators, both melodic-or-rhythmic, both from the same family of “small grammar, large output.”
An L-system rewrites a string by replacing every symbol at once, over and over —
Lindenmayer invented them in 1968 to model how plants branch. The famous Fibonacci
rule (A → AB, B → A) grows golden-ratio-length strings; map a symbol to a drum
and you get a self-similar rhythm. p.lsystem(pitch_map, axiom, rules, generations, ...) expands the string, then walks it placing a note for every
mapped symbol (unmapped symbols are silent rests):
composition = subsequence.Composition(bpm=120, seed=1)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def fib_kick(p):
# The Fibonacci rule: hits land at golden-ratio spacings, auto-fitted to the bar.
p.lsystem(
pitch_map={"A": "kick_1"}, # "A" sounds; "B" is a rest
axiom="A",
rules={"A": "AB", "B": "A"},
generations=6,
velocity=90,
)
composition.render(bars=4, filename="lsystem.mid")
With spacing=None (the default) the whole expanded string is fitted into the bar,
so each extra generation doubles the density while keeping the shape. Give a fixed
spacing= instead and the string is truncated to fit, holding the density constant.
Rules can be stochastic — {"A": [("AB", 3), ("BA", 1)]} picks AB three times
as often as BA, the same weighted-choice idiom you met for graph forms in
§10.1.
A Markov chain walks a graph of states, where the next state depends only on the
current one. p.markov(transitions, pitch_map, spacing, ...) is exactly the
weighted-graph dict from forms, repurposed for notes: name some states, give each a
list of (next_state, weight) moves, map states to pitches, and the walk places one
note per spacing beats. Here is a bassline that anchors on the root and wanders
through chord tones:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=3)
@composition.pattern(channel=2, beats=4)
def bass(p):
p.markov(
transitions={
"root": [("third", 3), ("fifth", 2), ("root", 1)], # usually moves on
"third": [("fifth", 3), ("root", 2)],
"fifth": [("root", 3), ("third", 1)], # tends home
},
pitch_map={"root": 45, "third": 48, "fifth": 52}, # absolute MIDI notes
velocity=90, spacing=0.5, duration=0.4,
)
composition.render(bars=4, filename="markov.mid")
spacing=0.5 places an eighth-note walk; the weights give it “stylistic coherence
without being perfectly repetitive,” as the move toward the root from every state
pulls the line home. A state absent from pitch_map is walked but stays silent — a
clean way to bake rests into the chain.
Note
The same (target, weight) vocabulary runs through the whole library. Graph
forms (§10.1), generated progressions (Chapter
7), stochastic L-system rules, and Markov chains all speak it.
Learn it once for forms and you can read every generator that walks a graph — the
“learn one verb, predict the rest” principle from §4.5,
now at the scale of whole subsystems.
12.3 Strange attractors and noise¶
The generators so far were discrete — cells flip, symbols rewrite. This section is continuous: smooth fields and chaotic trajectories you sample for pitch, velocity, and density.
The Lorenz attractor is three coupled equations whose trajectory never repeats,
never settles, and diverges from nearby starting points — Edward Lorenz’s 1963
“butterfly effect.” p.lorenz(pitches, ...) integrates it and uses the three
axes as correlated-but-independent modulation: by default x picks the pitch from your
pool, y drives velocity, z drives duration. The magic move is seeding x0 from the
cycle, so each bar takes a slightly divergent path — a phrase that is always related
yet never quite the same:
composition = subsequence.Composition(bpm=120, key="C", scale="dorian", seed=15)
scale = scale_notes("C", "dorian", low=60, high=84)
@composition.pattern(channel=4, beats=4)
def chaos_lead(p):
# x0 nudged by the cycle: each bar diverges gently from the last.
p.lorenz(scale, spacing=0.25, velocity=(50, 110), x0=p.cycle * 0.002)
composition.render(bars=4, filename="lorenz.mid")
Reaction-diffusion is Turing’s 1952 model of how two chemicals spreading at
different rates spontaneously make spots and stripes — leopard spots, coral, zebra.
p.reaction_diffusion(pitch, ...) simulates it on a ring of cells and thresholds
the result into a hit pattern with an organic, biological feel. The feed_rate and
kill_rate select the pattern regime — tiny changes move between dramatically
different rhythms:
composition = subsequence.Composition(bpm=120, seed=21)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def organic_perc(p):
p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)
# A spotted side-stick rhythm grown from a chemical simulation.
p.reaction_diffusion("side_stick", threshold=0.4, feed_rate=0.037,
kill_rate=0.060, velocity=(40, 80))
composition.render(bars=4, filename="reaction-diffusion.mid")
Then there are the noise fields in subsequence.sequence_utils — plain functions
you call yourself and feed into any parameter. They differ in character:
Function |
Character |
Reach for it when |
|---|---|---|
|
Smooth, slow drift — one value at position |
You want a parameter to wander gradually across bars. |
|
Tunable order→chaos via |
You want controllable instability — periodic at low |
|
1/f noise: slow drift and fast jitter together. |
You want statistically “natural” variation, like real music. |
perlin_1d returns a single smooth value for a position — drive it from p.cycle
for bar-to-bar drift. Here it controls how sparse a hi-hat is, thinning it with the
p.thin transform from §12.6:
composition = subsequence.Composition(bpm=120, seed=33)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drifting_hats(p):
p.hit_steps("hi_hat_closed", range(16), velocity=70)
# A smooth field decides how much to thin out, bar by bar.
density = su.perlin_1d(p.cycle * 0.08, seed=42)
p.thin("hi_hat_closed", "strength", amount=1.0 - density)
composition.render(bars=8, filename="perlin-thin.mid")
logistic_map and pink_noise return whole lists you map straight onto a step
sequence’s velocities — the parallel-list convention from Chapter
3:
composition = subsequence.Composition(bpm=120, seed=44)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def chaotic_shaker(p):
chaos = su.logistic_map(r=3.7, steps=16) # deterministic chaos
p.sequence(steps=range(16), pitches="shaker",
velocities=[round(30 + 60 * v) for v in chaos], durations=0.1)
# …and a maracas layer shaded by 1/f pink noise — natural-feeling variation.
noise = su.pink_noise(steps=16, seed=11)
p.sequence(steps=range(16), pitches="maracas",
velocities=[round(35 + 55 * n) for n in noise], durations=0.1)
composition.render(bars=4, filename="logistic-shaker.mid")
Note
logistic_map and pink_noise are deterministic for a given input — same r
and steps, same list every time; same seed for perlin/pink, same field. That
is why they need no Composition seed to be reproducible: the field is fixed, and
only where you sample it (via p.cycle) moves. Mix them freely with seeded
generators.
A random field, and firing steps from it. The noise fields above are
structured — each has its own characteristic shape. When you want a fresh,
uncorrelated value per step instead, build one with a list comprehension over the
pattern’s seeded generator, p.rng (§11.2) — there’s no
helper because the comprehension is the idiom. Whatever the field, structured or
random, density_to_steps turns it into hits: it rolls each step independently
against its density and returns the fired step indices, ready for
p.sequence(steps=...).
import subsequence.sequence_utils as su
composition = subsequence.Composition(bpm=120, seed=5)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def stochastic_perc(p):
# A fresh random density field — drawn from p.rng (seeded), never global random().
density = [p.rng.random() for _ in range(16)]
# Roll each step independently against its density; get back the fired indices.
steps = su.density_to_steps(density, p.rng)
p.sequence(steps=steps, pitches="hi_hat_closed",
velocities=[40 + int(density[i] * 50) for i in steps], durations=0.1)
composition.render(bars=4, filename="density-steps.mid")
Drawing from p.rng (not the global random.random()) keeps the field
reproducible under the composition seed. density_to_steps(density, p.rng) is the
named form of the hand-written [i for i in range(n) if p.rng.random() < density[i]] — each step an independent coin weighted by its density, so over many
cycles step i fires a density[i] fraction of the time. A bare float needs an
explicit length (a per-step list carries its own):
su.density_to_steps(0.3, p.rng, length=16) scatters ghosts at ~30% across sixteen
steps. An empty result is normal — a sparse field may fire nothing on a cycle, and
p.sequence then simply places nothing.
These stochastic gates are easy to confuse with their deterministic and hit-thinning cousins, which live next to them; reach for the right one:
You have… |
You want… |
Reach for |
|---|---|---|
a density profile (floats in |
deterministic — fire every step above a fixed level |
|
a density profile |
stochastic — roll each step independently against its density |
|
an existing 0/1 rhythm |
randomly thin the hits you already have |
|
12.4 Number-theoretic generators¶
These produce patterns from pure number theory — fixed sequences with deep structure, no randomness at all (so they repeat identically without a seed).
The Thue-Morse sequence (0 1 1 0 1 0 0 1 …) is built by negating and appending:
it is aperiodic — never strictly repeating — yet perfectly balanced and overlap-free.
p.thue_morse(pitch, ...) places hits where the sequence is set; in two-pitch
mode (give pitch_b) every step sounds, alternating two voices. It feels structured
but never settles into a loop, unlike a Euclidean rhythm:
composition = subsequence.Composition(bpm=120)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def tm_groove(p):
# Two-pitch mode: kick and snare trade places on a never-repeating pattern.
p.thue_morse("kick_1", pitch_b="snare_1", velocity=100)
composition.render(bars=4, filename="thue-morse.mid")
A de Bruijn sequence B(k, n) contains every possible length-n subsequence over
an alphabet of k symbols exactly once. p.de_bruijn(pitches, window, ...) maps
that onto a melody that systematically explores every window-note transition —
every two-note (or three-note) move through your pitch pool, once each:
composition = subsequence.Composition(bpm=120, key="C", scale="minor")
@composition.pattern(channel=4, beats=4)
def exhaustive_lead(p):
# Every ordered pair of these five pitches appears exactly once.
p.de_bruijn([60, 62, 64, 67, 69], window=2, velocity=(60, 100))
composition.render(bars=4, filename="de-bruijn.mid")
p.fibonacci(pitch, count, ...) places count events at golden-angle positions
— irrational, off-grid spacing that sounds organic and never lines up metronomically:
composition = subsequence.Composition(bpm=120)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def golden_hats(p):
p.fibonacci("hi_hat_closed", count=11, velocity=(60, 90))
composition.render(bars=4, filename="fibonacci.mid")
A self-avoiding walk steps ±1 through a pitch index and refuses to revisit a
pitch until it gets trapped and resets — guaranteeing diversity within each phrase
and natural phrase boundaries at the resets. p.self_avoiding_walk(pitches, ...)
gives a smooth, stepwise melody with occasional reversals:
composition = subsequence.Composition(bpm=120, key="C", scale="ionian", seed=2)
scale = scale_notes("C", "ionian", low=60, high=72)
@composition.pattern(channel=4, beats=4)
def walking_line(p):
p.self_avoiding_walk(scale, spacing=0.25, velocity=(60, 100))
composition.render(bars=4, filename="self-avoiding-walk.mid")
Tip
Mix the families for contrast. A Thue-Morse kick (fixed, aperiodic) under a self-avoiding-walk lead (random but diverse) over a Perlin-drifting hat (smooth) gives three independent textures from three different mathematics — the layered ideal the README calls “the algorithms are the vocabulary; the rebuild engine is the grammar.”
Reference
thue_morse(),
de_bruijn(),
fibonacci(),
self_avoiding_walk()
12.5 Evolve and branch¶
The last two generators take a melody you supply and develop it over time, so you keep authorial control of the material while the process handles the variation.
p.evolve(pitches, length, drift, ...) loops a pitch sequence that gradually
mutates: on cycle 0 it plays your seed exactly, then each later cycle every step has a
drift probability of being redrawn from the pool. At drift=0.0 it is a locked
loop; near 1.0 it dissolves into noise. The buffer persists across rebuilds (stored
in p.data, the shared-state mechanism from §2.5), so the drift
accumulates — the line slowly walks away from where it began:
composition = subsequence.Composition(bpm=120, key="C", scale="dorian", seed=4)
@composition.pattern(channel=4, beats=4)
def evolving_line(p):
# An 8-step loop that diverges a little each bar; snap keeps it in key.
p.evolve([60, 62, 64, 65, 67, 69], length=8, drift=0.12,
velocity=(70, 100), spacing=0.5)
p.snap_to_scale("C", "dorian")
composition.render(bars=8, filename="evolve.mid")
Note the crucial keyword: length (the number of steps in the loop), not
steps. Pairing evolve with p.snap_to_scale (§5.2) is
the idiom that keeps drifted pitches musical — the drift chooses which pool note,
the snap keeps it in the mode.
p.branch(pitches, depth, path, ...) navigates a fractal tree of classical
transforms (retrograde, inversion, transposition, rotation, interval scaling). Each
path index selects a route down the tree to one variation, always structurally
related to the input. Set path=p.cycle and the piece walks all 2 ** depth
variations in order, wrapping automatically — a self-developing theme:
composition = subsequence.Composition(bpm=120, key="C", scale="minor", seed=8)
@composition.pattern(channel=4, beats=4)
def branching_theme(p):
# depth=3 → 8 variations; path=p.cycle steps through them, one per bar.
p.branch([60, 64, 67, 72], depth=3, path=p.cycle, velocity=85, spacing=0.5)
p.snap_to_scale("C", "minor")
composition.render(bars=8, filename="branch.mid")
Note
evolve drifts; branch develops. evolve is a one-way random walk away from
the seed — there is no going back, only further. branch is deterministic and
reversible: path=0 always gives the same variation, so you can navigate the tree
on purpose (jump to the retrograde, then the inversion) or let p.cycle tour it.
Reach for evolve when you want erosion over time, branch when you want a catalogue
of related shapes.
12.6 Textural percussion¶
A cluster of verbs exists for the micro-detail layer — the ghost notes, shaker
clouds, and interlocking textures that sit under the main hits. You met the Bresenham
family in Chapter 4; here it earns
its place alongside ghost_fill and two thinning verbs that subtract rather than add.
p.bresenham_poly(parts, ...) distributes several voices across the grid so they
never collide — each step belongs to exactly one voice, weighted by density. It is
the engine for an interlocking texture bed:
composition = subsequence.Composition(bpm=120, seed=12)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def texture_bed(p):
# Hats and a shaker interlock — one voice per step, never overlapping.
p.bresenham_poly(
parts={"hi_hat_closed": 0.5, "shaker": 0.2},
velocity={"hi_hat_closed": 70, "shaker": 45},
)
p.hit_steps("kick_1", [0, 8], velocity=110) # anchors CAN overlap
composition.render(bars=4, filename="bresenham-poly.mid")
p.ghost_fill(pitch, density, bias, ...) scatters probability-biased ghost notes
around your anchors; p.thin(pitch, strategy, amount, ...) is its inverse,
removing notes by rhythmic position; and p.dropout(probability) is the bluntest
subtraction — it deletes whole step positions at random. Chained together they sculpt
a busy line down to taste:
composition = subsequence.Composition(bpm=120, seed=55)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def sculpted_snare(p):
p.hit_steps("snare_1", [4, 12], velocity=100) # backbeat anchors
# ADD a dense ghost layer on the 16ths…
p.ghost_fill("snare_1", density=0.5, velocity=(25, 45), bias="sixteenths")
# …then THIN most of it back out, weakest positions first…
p.thin("snare_1", "sixteenths", amount=0.7)
# …and DROP a few remaining positions at random for looseness.
p.dropout(0.1)
composition.render(bars=4, filename="textural-snare.mid")
Important
thin/dropout operate on whatever is already placed, so order matters. They
read the notes currently in the builder and remove some — call them after the
ghost_fill/hit_steps that placed the candidates, never before. This is the
post-placement-transform model: build up, then carve down. (dropout removes whole
positions, so a chord’s voices live or die together — fine for drums, worth knowing
for pitched material.)
Reference
12.7 The Conductor: LFOs, ramps, signals¶
The generators above vary within and across bars. The Conductor varies things
over long stretches — a swell that breathes across eight bars, a filter that opens
over a whole section, a level that fades in over a minute. It holds named,
time-varying signals on composition.conductor, and any pattern reads one with
p.signal(name), getting a plain float to scale a velocity, a CC, or a note
choice.
There are two kinds. An LFO cycles forever; a line ramps once from one value to another. Register them on the conductor, then read them by name:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=60)
# A slow sine swell (one full cycle every 32 beats = 8 bars), riding 0.3 → 1.0…
composition.conductor.lfo("swell", shape="sine", cycle_beats=32, min_val=0.3, max_val=1.0)
# …and a one-shot fade-in over the first 16 bars, smoothly eased.
composition.conductor.line("fadein", start_val=0.0, end_val=1.0,
duration_beats=64, shape="ease_in_out")
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def breathing_hats(p):
swell = p.signal("swell") # 0.3 → 1.0 → 0.3, cycling
fade = p.signal("fadein") # 0.0 → 1.0 once, then holds at 1.0
velocity = int(40 + 70 * swell * fade)
p.hit_steps("hi_hat_closed", range(16), velocity=velocity)
composition.render(bars=8, filename="conductor.mid")
p.signal reads the value at the current bar, so it changes once per bar, not per
note — exactly right for the slow, macro-level modulation a conductor is for. The LFO
shape is "sine", "triangle", "saw", or "square"; a line’s shape is an
easing name ("linear", "ease_in_out", "exponential", …). An unregistered name
returns 0.0 and warns once, so a typo fails loudly rather than silently.
Verb |
Shape over time |
Use it for |
|---|---|---|
|
Periodic, forever. |
Swells, tremolo, slow filter wobble, breathing density. |
|
One-shot ramp (optionally |
Fade-ins, build-ups, a sweep that opens over a section. |
One more long-arc tool lives on the Composition itself rather than the conductor:
composition.target_bpm(bpm, bars, shape) smoothly ramps the tempo to a target
over a number of bars — a written accelerando or rallentando, the macro counterpart
of everything above:
composition = subsequence.Composition(bpm=120, seed=61)
# Accelerate from 120 to 140 BPM over the next 8 bars with a smooth S-curve.
composition.target_bpm(140, bars=8, shape="ease_in_out")
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def accelerating(p):
p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100)
composition.render(bars=8, filename="target-bpm.mid")
Note
Conductor vs. Data vs. Energy — three ways to vary over time. p.data
(§2.5) is state one pattern writes for another to read; a
conductor signal is a pure function of time that any pattern reads; section
energy (§10.3) is the arranging dial bound to the form. They
compose: a hat might multiply a conductor swell by the section energy and gate on
p.data — three independent dials shaping one velocity.
Reference
12.8 Scored melody with MelodicState¶
The melodic generators in §12.3 and
§12.4 walk pitches by geometry. MelodicState walks
them by cognition: it scores every candidate pitch with the Narmour
Implication-Realization model — the same framework the chord engine uses (Chapter
6), now on absolute pitch. After a large leap it expects a
reversal; after a small step it expects continuation; it pulls toward chord tones and
the tonic, and penalises recent repetition. The result is a line that sounds
phrased, not random.
The key idea is that the state is persistent — it lives at module level, outside
the pattern builder, so its pitch history survives the bar rebuilds and melodic
continuity is maintained for you. This is exactly the module-level-value habit from
§2.4: a thing you want to remember across cycles must outlive
the fresh canvas the rebuild hands you each bar. You build it once, then call
p.melody(state, ...) each cycle:
# Built ONCE at module level so its history survives every rebuild.
melody_state = subsequence.MelodicState(
key="A", mode="aeolian",
low=60, high=84, # the register the line may roam
nir_strength=0.6, # how strongly the cognitive rules bite (0–1)
chord_weight=0.4, # pull toward the current chord's tones
)
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=70)
composition.harmony(style="aeolian_minor", cycle_beats=4)
@composition.pattern(channel=4, beats=4)
def scored_lead(p, chord):
tones = chord.tones(72) if chord else None # this bar's chord tones
p.melody(melody_state, spacing=0.5, velocity=(70, 100), chord_tones=tones)
composition.render(bars=8, filename="melodic-state.mid")
Passing chord_tones= (from the injected chord, declared as a parameter exactly as
in Chapter 6) lets the line lean into the harmony as it changes
— the melody follows the chords without you choosing a single note. A bare
MelodicState() with no key/mode adopts the composition’s key the first time it
runs, so it slots into a keyed piece with no configuration.
```{list-table} MelodicState dials
- header-rows:
1
- widths:
30 70
Argument
What it shapes
nir_strengthHow strongly the expectation rules steer choice.
0.0= uniform,1.0= full.
chord_weightBonus for candidates that are chord tones — higher hugs the harmony.
rest_probabilityChance of a rest at any step — breathing room in the line.
pitch_diversityPenalty for repeating a recent pitch — lower discourages repetition harder.
low/highThe MIDI register the line is confined to.
The same state can also seed a *value*. **`Motif.generate(..., state=)`**
([Chapter 8](08-motifs)) walks a one-off motif using a `MelodicState`'s settings and
history — but it **copies** the state first, so generating a value never disturbs your
live, module-level object:
```{doctest} ch12
>>> # A reusable hook scored by the same taste, captured as an immutable Motif:
>>> seed_state = subsequence.MelodicState(key="A", mode="aeolian", low=60, high=84, nir_strength=0.7)
>>> hook = subsequence.Motif.generate(
... rhythm=[0, 1, 1.5, 1.75, 2.5], # rhythm first, pitches walked over it
... scale="minor", contour="arch", end_on=1,
... seed=7, state=seed_state,
... )
>>> print(hook.describe())
Motif 4 beats [^3@0, ^6@1, ^4+@1.5, ^5@1.75, ^1@2.5]
Note
p.melody performs; Motif.generate captures. Use p.melody for a live,
continuous line that responds to the chord cycle by cycle and never repeats. Use
Motif.generate(state=) when you want a fixed phrase you can name, transform,
develop, and bind to a section (§10.5) — the explore-then-capture
arc of Chapter 11, applied to melody. The copy-on-generate
rule means the two never interfere.
Reference
12.9 The pattern-transforms toolkit¶
Whatever placed the notes — a hand-written list, a cellular automaton, a Markov walk
— the result is just notes on the grid, and the transforms toolkit reshapes them
after the fact. You met several in passing (snap_to_scale, thin, swing); here
is the core set as one vocabulary. Every one mutates the notes already in the builder
and returns self, so they chain.
Transform |
What it does |
|---|---|
|
Shift every pitch up or down by semitones. |
|
Mirror every pitch around a pivot note (default 60) — a melodic inversion. |
|
Retrograde: flip the pattern backwards in time around the downbeat. |
|
Rotate the pattern by grid steps, wrapping — the step-sequencer shift. |
|
Scale time: |
|
Change the pattern’s length in beats from this cycle on. |
|
Apply a transform only every |
|
Multiply velocities by a per-step list — ducking, accents, fades. |
|
Returns a list — a per-step velocity sweep to feed |
Note
Retrograde a raw list with plain Python. p.reverse() above flips the notes
already placed in the builder. When you instead want to reverse a per-step list
before you use it — a 0/1 rhythm mask, a density profile, a velocity curve — slice it
in reverse: cell[::-1] turns [1, 0, 0, 1, 0, 1, 0, 0] into
[0, 0, 1, 0, 1, 0, 0, 1]. There’s deliberately no helper, because it’s a one-line
Python idiom with no musical name to earn one. Use [::-1] on the list you feed
in; use p.reverse() (or a Motif/Phrase .reverse(),
§8.3) on material already placed as musical events.
p.stretch and p.set_length work on time rather than pitch. stretch(factor)
scales note positions and durations — 2.0 for a half-speed augmentation, 0.5 for
a double-speed diminution — and set_length changes how many beats the pattern
occupies from this cycle on, so a loop can grow or shrink as the piece develops:
composition = subsequence.Composition(bpm=120, key="C", scale="minor", seed=88)
@composition.pattern(channel=4, beats=4)
def augmented(p):
# A tight 8-step loop on the first half of the bar.
p.evolve([60, 62, 65, 67], length=8, drift=0.0, spacing=0.25, velocity=90)
if p.cycle % 2 == 1:
# On odd cycles, grow the bar to 8 beats and slow the line into it (half-time).
p.set_length(8.0) # grow the 4-beat window to 8…
p.stretch(2.0) # …and slow the line to fill it
composition.render(bars=6, filename="stretch-setlength.mid")
Here the transforms reshape a de Bruijn line into something that develops bar by bar — the generator supplies the raw material, the transforms make it move:
composition = subsequence.Composition(bpm=120, key="C", scale="minor", seed=9)
@composition.pattern(channel=4, beats=4)
def reshaped(p):
p.de_bruijn([60, 63, 67, 70], window=2, velocity=80, spacing=0.25)
p.transpose(-12) # drop it an octave
p.every(2, lambda p: p.reverse()) # retrograde on alternate bars
p.every(4, lambda p: p.invert(60)) # inversion every 4th bar
p.rotate(2) # nudge the whole thing 2 steps later
composition.render(bars=8, filename="transforms.mid")
build_velocity_ramp and scale_velocities are the velocity pair: the first builds
a per-step sweep with an easing curve, the second applies a multiplier list to
whatever is placed. Together they shape a flat generated line into a swelling one:
composition = subsequence.Composition(bpm=120, seed=77)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def swelling_roll(p):
p.hit_steps("snare_1", range(16), velocity=100)
# A 16-step ramp 30 → 110, eased in — the snare roll swells into the downbeat.
p.scale_velocities([v / 110 for v in p.build_velocity_ramp(30, 110, "ease_in")])
composition.render(bars=4, filename="velocity-ramp.mid")
Note
Under the hood: place, then transform. Every generator in this chapter is a
placement verb — it adds notes to the builder. Every verb in this section is a
transform — it rewrites notes already there. The two phases are the whole model: a
pattern function builds a canvas of notes (§2.2) and then carves,
shifts, and shades it before the cycle is scheduled. Because transforms read the live
canvas, order is the composition: reverse() then rotate(2) is not the same as
rotate(2) then reverse(). Lay the notes down, then sculpt — and seed the wild
ones so today’s sculpture is there again tomorrow.
Reference
transpose(),
invert(),
reverse(),
rotate(),
stretch(),
set_length(),
every(),
scale_velocities(),
build_velocity_ramp()
You can now drive rhythm and pitch from cellular automata, L-systems, Markov chains,
strange attractors, and noise fields; develop a melody with evolve and branch;
layer textural percussion; modulate over long arcs with the Conductor and
target_bpm; score phrased lines from a persistent MelodicState; and reshape any of
it with the transforms toolkit — all reproducibly, on a seed. Chapter
13 turns from what to play to how it sounds at the
instrument: continuous controllers, pitch bend and portamento, NRPN and SysEx,
groove import, multi-device routing, and microtonal tuning. Then Chapter
14 takes it all live — and from there, the appendices open the
power-user path: the Direct Pattern API, the analysis and set-theory toolkit, MIDI
routing reference, and the full API catalogue.