Chapter 11 · Explore, Capture, Produce: Seeds, Locks, Freeze¶
In Chapter 10 you arranged a whole track — sections, energy, fills, a graph form that picks its own path. That power comes with a producer’s problem: the moment your music makes its own choices, the version you loved at 2am might be gone by morning. This chapter is about the other half of generative work — keeping the version you like and recreating it tomorrow.
Everything you’ve built leans on randomness somewhere: a humanised hi-hat, a euclidean snare, a generated phrase, a graph form. Run the script again and those draws could land differently. The whole chapter answers one question in six ways: how do I pin a take down? You’ll make a piece reproducible from a single seed; control randomness per pattern and per generator (and learn which wins); re-seed correctly while playing live; lock the parts you like and reroll the ones you don’t — printing the seed so a happy accident survives a restart; freeze a live progression into editable data; and finally record or render the finished take to a file you can ship.
11.1 One seed → a reproducible piece¶
You met seed= in passing as far back as §4.6 and used
it to pin a generated progression (§7.1) and a graph form
(§10.1). Here’s the whole idea stated plainly: a seed is a
single number that makes every random decision in the piece repeatable. Set it
once on the Composition, and the humanised velocities, the euclidean rolls, the
generated melodies, the graph-form walk — all of it — comes out the same on every
run.
import subsequence
import subsequence.constants.instruments.gm_drums as gm_drums
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=20240615)
composition.harmony(style="aeolian_minor", cycle_beats=4)
@composition.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)
p.hit_steps("hi_hat_closed", range(16), velocity=(50, 90)) # random per hit…
p.euclidean("snare_1", pulses=5) # …and a generated rhythm
@composition.pattern(channel=2, beats=4)
def bass(p, chord):
p.note(chord.bass_note(40), beat=0, duration=3.5, velocity=95)
composition.render(bars=8, filename="seeded.mid")
Render that twice and you get byte-for-byte the same MIDI file. The
velocity=(50, 90) hi-hats wobble, but they wobble identically each run; the
euclidean snare lands the same; the harmony walks the same chords. The seed turns
“generative” from “different every time” into “the same every time unless I change
the recipe” — which is exactly what you want once you’ve found a take you like.
Important
Seed early, seed always. Pass seed= the moment a piece contains any
randomness you’d want to reproduce — which is almost immediately. Without it, every
random draw reseeds from the system clock, so the file you render now and the file
you render after lunch are different pieces. A seeded composition is a recipe: the
same ingredients in the same order give the same dish. An unseeded one is a
performance you can never repeat.
The number itself is arbitrary — 42, 7, a date like 20240615, your phone’s
last four digits. What matters is only that it’s fixed in your script. Two
different seeds give two different pieces from the same code; the same seed always
gives the same piece.
Note
Under the hood: one seed, many independent streams. A single number couldn’t
keep a dozen patterns from interfering with each other, so Subsequence doesn’t hand
the raw seed to anyone. Instead it derives a named stream per consumer — each
pattern keyed by its function name (drums, bass, the harmony engine, the form
walk), every one a deterministic generator computed as a stable hash (crc32) of
"{seed}:{name}". (All of a pattern’s own randomness — the humanised hats and the
euclidean snare inside drums alike — flows through that one pattern stream.)
Because each stream is keyed by name rather than by call order, adding a new
pattern never shifts an existing one’s randomness — the drums sound the same
whether or not you’ve added a lead since. That naming scheme is what makes the seed
robust enough to build a whole track on, and it’s the machinery behind every tool
in the rest of this chapter.
11.2 Per-pattern and per-generator randomness (and precedence)¶
The composition seed is the master dial, but you don’t always want everything chained to it. Sometimes you want one pattern pinned while the rest stays free, or one generator call nailed to a specific result regardless of the master seed. Subsequence gives you three places to control randomness, in a strict order of precedence. Understanding that order is the difference between confident control and “why didn’t that change anything?”
The pattern’s own generator: p.rng¶
Every pattern builder carries p.rng — an ordinary Python
random.Random
instance, seeded automatically from the master seed via the named stream from
§11.1. It’s the generator that all of a pattern’s randomness
flows through by default: velocity=(50, 90), a euclidean with no seed=, an
arpeggio with direction="random". You can also use it directly when you want a
custom random choice that still keys off the composition seed:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=20240615)
@composition.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)
# A reproducible coin-flip: open the hat on the last 'and' two times in three.
if p.rng.random() < 0.66:
p.hit_steps("hi_hat_open", [14], velocity=90)
composition.render(bars=4, filename="prng.mid")
Because p.rng is seeded from the master, that coin-flip lands the same way every
run — and a different way if you change the composition seed. Reaching for
p.rng rather than Python’s bare random.random() is the habit that keeps your
own logic inside the reproducible recipe.
Per-generator overrides: seed= and rng=¶
Every generative method — euclidean, bresenham, arpeggio, snap_to_scale,
and the rest — takes two optional knobs that override p.rng for that one call:
seed=(an int) — the friendly knob. Pins this call to a fixed result no matter what the composition seed is. Use it to nail one rhythm you love while the rest of the pattern stays tied to the master.rng=(arandom.Random) — the advanced form. Hand a call a generator you built yourself, for when you want several calls to share one stream, or to drive a generator from external data.
import random
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=20240615)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
p.euclidean("kick_1", pulses=4) # uses p.rng (master-derived)
p.euclidean("snare_1", pulses=5, seed=12345) # pinned to THIS result, always
p.euclidean("hi_hat_closed", pulses=7,
rng=random.Random(99)) # driven by a generator you own
composition.render(bars=4, filename="overrides.mid")
The precedence, stated once¶
When more than one is present, the most explicit wins:
Priority |
You passed… |
What runs |
|---|---|---|
1 (highest) |
|
Your explicit generator. If you also passed |
2 |
|
A fresh |
3 (default) |
neither |
|
Warning
rng= and seed= together is a mistake, and Subsequence says so. Passing both
means you’ve expressed two intentions; rng= wins and seed= is silently irrelevant
except for the warning it triggers. Pick one: seed= for “pin this to a number,”
rng= for “use this generator I’m holding.” Most of the time you want neither —
let p.rng carry it, and control everything from the one composition seed.
Tip
When to override, when to leave it. Reach for a per-call seed= when you’ve
generated a rhythm or melody you specifically want frozen — a euclidean snare that
has to be those five hits — while letting the master seed govern the humanising
wobble around it. Leave every call on p.rng (the default) when you’re happy to
re-roll the whole piece by changing one number at the top. The locks in
§11.4 give you a third, live way to pin a part without
hard-coding any number at all.
11.3 Re-seeding live correctly¶
When you’re performing — running play() with file-watching (Chapter 14)
— you’ll sometimes want to re-roll a pattern’s randomness on the fly: nudge it
off its current variation into a fresh one without restarting. There’s a right way
and a wrong way, and the wrong way fails silently, which is why it’s worth a
section of its own.
The trap comes from how the rebuild loop works (Chapter 2).
Remember that your pattern function re-runs every cycle, and the p it receives is
a fresh builder each time — but that builder’s p.rng is wired up from a
persistent generator the pattern owns across cycles. That distinction decides
everything:
p.rng.seed(n)mutates the persistent generator in place. The pattern owns onerandom.Randomobject for its whole life; seeding it changes that object, so the new seed sticks on every future rebuild.p.rng = random.Random(n)only rebinds the throwaway builder’s attribute. The builder is discarded at the end of the cycle, and next cycle a new builder is wired from the unchanged persistent generator — so your reassignment silently reverts. It appears to work for one cycle, then vanishes.
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=20240615)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
# CORRECT: mutate the persistent generator. A new seed here persists across
# rebuilds, so the re-rolled variation is stable from now on.
if p.cycle == 0:
p.rng.seed(777)
p.euclidean("kick_1", pulses=4)
p.hit_steps("hi_hat_closed", range(16), velocity=(50, 90))
composition.render(bars=8, filename="reseed.mid")
Warning
Never reassign p.rng to re-seed. Writing p.rng = random.Random(777) inside
a pattern body is the classic silent bug: it changes randomness for the cycle you
can see and then reverts, because the engine rebuilds each cycle’s builder from the
pattern’s own long-lived generator — not from the builder you scribbled on. If you
want a seed change to stick, call p.rng.seed(n) (mutate it) — or, better,
don’t hand-seed inside the body at all and use the engine-level reroll() /
lock() from §11.4, which are built precisely for this
and survive a live reload.
Note
For almost all live re-seeding you should prefer composition.reroll(name) over
touching p.rng by hand. reroll() deals the named pattern a fresh deterministic
seed and prints it, so the new variation is reproducible and you can write the seed
down; hand-mutating p.rng inside the body buries the seed in your source. The
p.rng.seed(n) form is worth knowing so you recognise — and avoid — the
reassignment trap, but the engine-level tools are the workflow.
11.4 Lock and reroll¶
Here is the heart of the producer’s loop, and the reason the named-stream machinery exists. You’re auditioning a generative track. The drums are perfect; the lead is almost right. You want to freeze the drums and keep dealing the lead new hands until one clicks — then pin that one so it survives the next restart. Four methods do exactly this, all keyed by pattern name:
Method |
What it does |
|---|---|
|
Pin a pattern to its current effective seed. It re-deals the same stream every
rebuild, so it realizes identically forever — and |
|
Release a lock. The pattern runs free again and |
|
Deal the pattern a fresh deterministic seed — a new variation — and print the new effective seed. Refuses on a locked pattern. |
|
Return the pattern’s current effective seed without changing anything — the number you note down to recreate this exact variation. |
The workflow reads like a producer talking to themselves:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=2024)
composition.harmony(style="aeolian_minor", cycle_beats=4)
@composition.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums(p):
p.euclidean("kick_1", pulses=4)
p.euclidean("snare_1", pulses=5)
@composition.pattern(channel=4, beats=4)
def lead(p, chord):
# direction="random" shuffles the arpeggio order from p.rng — so a reroll of
# 'lead' (which re-deals that stream) genuinely deals a new ordering.
p.arpeggio(chord, root=72, count=6, spacing=0.5, velocity=80, direction="random")
# "The drums are perfect — lock them so nothing can disturb that."
composition.lock("drums")
# "The lead isn't there yet — deal me another, and another."
composition.reroll("lead")
composition.reroll("lead")
# "That one! Write down its seed so I can get it back tomorrow."
print("keeper lead seed:", composition.seed_for("lead"))
composition.render(bars=8, filename="lock-reroll.mid")
reroll('lead') -> effective seed 3908473859 (nonce 1)
reroll('lead') -> effective seed 1912587705 (nonce 2)
keeper lead seed: 1912587705
Read the printout top to bottom. Each reroll("lead") announces the new effective
seed it dealt; seed_for("lead") confirms the last one is what’s live. The
printed seed is your bridge across a restart — and you need it, because the reroll
nonce that produced it lives only in this running process: quit and come back, and
the lead is back to its first hand with no rerolls applied. So to make a discovered
variation permanent, write that number down and copy it into the call as a fixed
seed=:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=2024)
@composition.pattern(channel=4, beats=4)
def lead(p, chord):
# The seed reroll() printed, now hard-wired into the call: a fixed seed= pins
# this arpeggio's order so it renders the same on every run, restart and all.
p.arpeggio(chord, root=72, count=6, spacing=0.5, velocity=80,
direction="random", seed=1912587705)
@composition.pattern(channel=2, beats=4)
def bass(p, chord):
p.note(chord.bass_note(40), beat=0, duration=3.5, velocity=95)
composition.harmony(style="aeolian_minor", cycle_beats=4)
composition.render(bars=8, filename="pinned-lead.mid")
Important
Lock is engine-side; it survives a live reload. When you lock("drums") and
then edit-and-save your file under composition.watch() (Chapter 14),
the lock holds — locks live on the composition, not in the pattern’s source, so a
reload can’t wipe them. That’s the point: you lock the parts you’ve blessed, keep
hacking on the rest, and the blessed parts never flinch. reroll() deliberately
refuses on a locked name (and tells you to unlock() first) so you can’t
accidentally disturb a keeper.
Tip
reroll() is non-destructive — it just bumps a counter. Each reroll advances a
per-name nonce that salts the seed derivation, so the variations form a stable
sequence: the first reroll of lead always gives the same hand, the second always
the next, and so on for a given master seed. You can reroll past a good one and walk
back to it by counting, but the reliable move is to read seed_for() at the keeper
and pin the number. Rerolling never consumes or corrupts anything — unlock() and a
fresh start always return you to the untouched master-derived stream.
Reference
11.5 Freezing live harmony into data¶
The graph form had form_freeze() (§10.6) — capture a live
structural walk into an editable Form. Harmony has the exact twin:
composition.freeze(bars) runs the live chord engine forward and captures the
chords it would play into an ordinary Progression value
(Chapter 7) — one you can print, edit, and bind to a section.
This is how you turn “the engine improvised something gorgeous” into “a written
progression I own.”
freeze(bars) captures bars chord changes from wherever the engine currently is.
The engine advances as it goes, so successive freezes read like one continuous
journey — perfect for handing each section its own slice of a coherent whole:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=7)
composition.harmony(style="aeolian_minor", cycle_beats=4)
# Capture the engine's next 8 chords for the verse, ending on the dominant so it
# leans into the chorus; then capture the following 4 for the chorus.
verse = composition.freeze(8, end="V")
chorus = composition.freeze(4)
print("verse :", [c.name() for c in verse.chords])
print("chorus:", [c.name() for c in chorus.chords])
verse : ['Am', 'Em', 'F', 'G', 'Am', 'Dm', 'Bdim', 'E']
chorus: ['F', 'G', 'Am', 'A#']
Each frozen value is a full Progression, so everything from Chapter 7 applies —
describe() it, replace() a chord, change the voicing — and then bind it to a
section with section_chords(), exactly as in §10.5. The
generated harmony becomes fixed, written data:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=7)
composition.harmony(style="aeolian_minor", cycle_beats=4)
verse = composition.freeze(8, end="V")
chorus = composition.freeze(4)
composition.form([("verse", 8), ("chorus", 4)], loop=True)
composition.section_chords("verse", verse) # frozen harmony, now bound as form data
composition.section_chords("chorus", chorus)
@composition.pattern(channel=2, beats=4)
def bass(p, chord):
p.note(chord.bass_note(40), beat=0, duration=3.5, velocity=95)
@composition.pattern(channel=3, beats=4, voice_leading=True)
def pad(p, chord):
p.chord(chord, root=52, velocity=70, sustain=True)
composition.render(bars=12, filename="frozen-harmony.mid")
The bass and pad never mention the freeze — they just follow the injected chord
(Chapter 6). What changed is that the chord underneath them is
now captured data rather than a live roll of the dice, so the track plays the same
progression every render. You’ve moved harmony from the “performance” column to the
“recipe” column.
Note
freeze() is reproducible without play(), and order-independent. Each call
draws from its own salted stream (freeze:1, freeze:2, …) under the composition
seed, so the captured chords are identical on every render and adding a third
freeze() can’t shift what the first two produced. That’s why the chord lists in
the output above are stable — freeze() isn’t reading a live performance, it’s
deterministically running the same engine the playback would. (freeze() needs
harmony() configured first; it raises a clear error otherwise.)
Reference
Tip
The two freezes are the same idea at two scales. composition.freeze(bars)
captures the harmonic clock into a Progression; composition.form_freeze()
(§10.6) captures the form clock into a Form. Both turn a
live, generative clock into an editable value you can inspect, tweak, and rebind —
the explore-then-capture loop, once for chords and once for structure. Jam with the
generators, freeze the take you like, edit the data, and render it the same way
forever.
11.6 Recording and offline rendering¶
You’ve pinned the seed, locked the keepers, frozen the harmony. The last step is getting the finished take out — into a MIDI file you can drop into a DAW, send to a collaborator, or print. There are two routes, and you’ve quietly been using one since Chapter 0.
Offline: render()¶
composition.render(bars, filename) runs the sequencer as fast as possible — no
wall clock, no instrument required — and writes a standard MIDI file. It’s the route
every example in this book ends with, because it’s the fastest way to prove your
code does what you meant, and it needs no hardware. Everything that happens live
happens in a render too: humanised velocities, generative fills, probabilistic
gates, BPM ramps. The only difference is that time is simulated:
composition = subsequence.Composition(bpm=120, key="A", scale="minor", seed=20240615)
composition.harmony(style="aeolian_minor", cycle_beats=4)
@composition.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)
p.euclidean("snare_1", pulses=5)
@composition.pattern(channel=2, beats=4)
def bass(p, chord):
p.note(chord.bass_note(40), beat=0, duration=3.5, velocity=95)
composition.render(bars=32, filename="final-take.mid")
bars= sets the length; leave it off and render() keeps going up to a 60-minute
safety cap (max_minutes=), so a never-ending generative piece still terminates.
Pair render() with a fixed seed= and you have a deterministic bounce: the same
script always writes the same file.
```{list-table} render() length controls
- header-rows:
1
- widths:
34 66
Call
Result
render(bars=32, filename="take.mid")Exactly 32 bars (the time cap still backstops it).
render(max_minutes=5, filename="take.mid")Up to 5 minutes — bound a generative piece by clock time instead of bars.
render(bars=128, max_minutes=None, filename="take.mid")Remove the time cap; you must give
bars=so it can’t run forever.
### Live capture: `record=True`
When you're performing through your instrument with `play()` and want to *keep* the
take as it happens, start the composition with **`record=True`** and name the file
with **`record_filename=`**. Subsequence writes every MIDI event it sends to that
file while it plays, so a live improvisation — hotkey jumps, rerolls, a graph form
walking its own path — is captured exactly as you performed it:
```{testcode} ch11
composition = subsequence.Composition(
bpm=120, key="A", scale="minor", seed=20240615,
record=True, record_filename="live-take.mid",
)
@composition.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)
composition.render(bars=8, filename="bounce.mid")
# In a real performance you'd end with play(), not render():
composition.play() # streams live to your instrument AND writes live-take.mid
The record=True capture only fills in during live play(). The example above is
executed with render() so the chapter stays headless; swap the last line for
play() at your instrument to capture an actual performance.
Important
Two routes, two jobs. render() is the deterministic bounce — fast, offline,
repeatable, the way you commit a finished arrangement to a file. record=True is the
performance recorder — it captures what you actually played live, including the
in-the-moment moves a render can’t know about (a jump you triggered by hand, a reroll
you liked). Use render() to ship the recipe; use record=True to keep a take you’d
never reproduce exactly. With a fixed seed they often agree to the byte — which is the
whole point of this chapter.
Note
Under the hood: the producer’s loop, closed. Every tool here turns a live,
generative thing into a fixed, shippable thing. The composition seed pins all the
dice at once; per-call seed=/rng= pin one throw; lock()/reroll() let you
audition and bless parts live while printing the seed that recreates them;
freeze() and form_freeze() capture the harmonic and form clocks into editable
data; render() and record=True write the result to disk. The same principle runs
through all of it — a generated choice is just a specification you can resolve, name,
and pin — so “explore until it’s good, then capture it” stops being a hope and
becomes a workflow.
Reference
You can now keep the version you like and recreate it tomorrow: one seed for a reproducible piece, per-pattern and per-generator control with a clear precedence, correct live re-seeding, lock-and-reroll to bless and audition parts, frozen harmony as editable data, and a finished take rendered or recorded to a file. That closes the production workflow. Chapter 12 opens the advanced path — deep generative systems, chaos and automata, scored melody — where these very seed-and-freeze habits become essential for taming far wilder material.