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.

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.

Reference

lsystem(), markov()

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:

Noise fields

Function

Character

Reach for it when

perlin_1d(x, seed)

Smooth, slow drift — one value at position x.

You want a parameter to wander gradually across bars.

logistic_map(r, steps)

Tunable order→chaos via r (try 3.7).

You want controllable instability — periodic at low r, chaotic near 4.

pink_noise(steps, seed)

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:

Which density gate?

You have…

You want…

Reach for

a density profile (floats in [0, 1])

deterministic — fire every step above a fixed level

threshold(density, cutoff) → a 0/1 list, then sequence_to_indices(...)

a density profile

stochastic — roll each step independently against its density

density_to_steps(density, p.rng) → fired indices directly

an existing 0/1 rhythm

randomly thin the hits you already have

probability_gate(sequence, prob, p.rng) → a parallel 0/1 list

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

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.

Reference

evolve(), branch()

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

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.

Conductor signals

Verb

Shape over time

Use it for

conductor.lfo(name, shape, cycle_beats, min_val, max_val)

Periodic, forever.

Swells, tremolo, slow filter wobble, breathing density.

conductor.line(name, start_val, end_val, duration_beats, shape=)

One-shot ramp (optionally loop=True).

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.

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_strength

    • How strongly the expectation rules steer choice. 0.0 = uniform, 1.0 = full.

    • chord_weight

    • Bonus for candidates that are chord tones — higher hugs the harmony.

    • rest_probability

    • Chance of a rest at any step — breathing room in the line.

    • pitch_diversity

    • Penalty for repeating a recent pitch — lower discourages repetition harder.

    • low / high

    • The 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.

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.

Pattern transforms

Transform

What it does

p.transpose(semitones)

Shift every pitch up or down by semitones.

p.invert(pivot)

Mirror every pitch around a pivot note (default 60) — a melodic inversion.

p.reverse()

Retrograde: flip the pattern backwards in time around the downbeat.

p.rotate(steps, grid=)

Rotate the pattern by grid steps, wrapping — the step-sequencer shift.

p.stretch(factor)

Scale time: 2.0 = half-speed (augmentation), 0.5 = double-speed (diminution).

p.set_length(length)

Change the pattern’s length in beats from this cycle on.

p.every(n, fn)

Apply a transform only every n-th cycle.

p.scale_velocities(factors)

Multiply velocities by a per-step list — ducking, accents, fades.

p.build_velocity_ramp(low, high, shape)

Returns a list — a per-step velocity sweep to feed velocities= or scale_velocities.

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.


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.