Skip to content

How Patterns Work

A pattern in resonon is not a list of notes stored somewhere. It is a description that answers one question: what plays in this cycle? Everything else about patterns follows from that. It is why transforms cost nothing until playback, why the order of a chain matters, why microtiming is measured in steps, and why generators give the same result on every playback.

Think of a pattern as a recipe, not a recording. When the transport runs, the scheduler asks each pattern for the events of one cycle at a time:

  • On demand. Only the cycle being played is computed.
  • Stateless. Each query is independent, so any cycle can be asked for, in any order.
  • Infinite by default. A pattern repeats forever without storing anything that grows.

Time is counted in ticks, 46,126,080 to the cycle, the way a sequencer counts in pulses. Every subdivision up to 16 and every binary one down to 1/1024 is a whole number of ticks, so a five-element sequence divides the cycle into exact fifths, and nothing drifts over hours of playback. A tick is far shorter than a sample, so anything finer simply sits on the nearest one.

Resonon’s pattern model is inspired by TidalCycles and its functional approach to time.

.fast(2), .reverse() and the other pattern methods don’t change any stored notes, because there are none. Each one returns a new pattern that wraps the one it was called on and changes how that pattern is queried. The original is never touched. It is like non-destructive editing in a DAW: the source clip stays as it was, and the edits stack on top.

Two consequences follow. First, you can derive as many variations from one motif as you like, and none of them affects the others. Second, the order of a chain changes the result, because each method wraps everything to its left:

print([C4 D4].concat([E4]).reverse().describe()); // reverses all three notes
print([C4 D4].reverse().concat([E4]).describe()); // reverses only [C4 D4]

.describe() prints that nesting. Read its output inside-out: Transpose(7, Reverse(Sequence[3])) is a three-note sequence, reversed, then transposed. The innermost term is the source, and each wrapper is one method you applied.

The microtiming methods (.nudge(), .swing(), .humanize()) move events off the grid. Every amount is a fraction of a slot, not an absolute time, so the feel follows the tempo.

Swing is a property of the grid, not of the events. .swing(amount, grid) cuts the cycle into grid slots and takes them in pairs. It leaves the first of each pair on the grid and delays the second by the amount, so the pair splits the time between them in the ratio 1 + amount to 1 - amount. At 0.33 that is two to one, the triplet shuffle of an MPC; at 0.5 it is three to one, a dotted note and the note after it. What plays in a slot doesn’t matter: in a stack, the kick on the beat stays and the hat on the off-beat moves. A note between two grid lines moves with its pair, by the amount times its distance to the nearer edge of the pair, so notes never pass each other.

.nudge() and .humanize() work on each event’s own step, the slot the event occupies, so humanize(0.1) loosens a four-step pattern and a sixteen-step one by the same feel.

The methods move notes in time. They don’t change their length:

  • A shifted note keeps its full duration, so it may overlap its neighbour or sustain past the end of the cycle. Small overlaps sound natural.
  • An event pushed outside the cycle is clamped to the cycle boundary. No event is dropped.
  • A zero amount, or an empty offset list, passes events through unchanged.

When you chain these methods, each one sees the events the previous one already moved. That is why .humanize() goes last: jitter applied before a swing moves the positions the swing is measured from.

Because any cycle can be asked for at any time, a pattern must give the same answer every time it is asked about the same cycle. Resonon keeps to that even where a pattern appears random:

  • Seeded randomness. rand(seed) and choose(seed, …) return the same value for the same seed. A generator seeded with the cycle number makes a different choice each cycle, and the same choice on every playback.
  • Hashed variation. .degrade() and .humanize() derive their “random” decisions from a hash of each event’s position. The same pattern drops the same hits and jitters the same way on every run.
  • Replayed state. A generator whose state carries from cycle to cycle, like a stream, a Markov chain or a script pattern, has no live state that could drift. To answer for cycle N, it replays every step from cycle 0.
use "std/generative" { markov };
let chain = markov(3, #[#[0.5,0.5,0.0],#[0.0,0.5,0.5],#[0.5,0.0,0.5]], 0);
assert chain.query(20) == chain.query(20), "the same cycle gives the same state";
print("deterministic");

This is what makes loops, restarts and seeking behave. Stop at cycle 40, seek back to cycle 12, and you hear exactly what played at cycle 12 the first time. The cost is that finding cycle N replays N steps (O(N)). Each step is cheap, so for musical ranges — hundreds to a few thousand cycles — you won’t notice it.

Two things follow from replaying. A generator’s function captures the variables it refers to when the generator is created, so changing them afterwards does not change the generator. And a script pattern’s fields are captured when the instance is sent to a track, so later changes to the original instance don’t reach the playing pattern.

rand_true() and choose_true() are the deliberate exception. They are unseeded and break seekability, so use them only when you want a result that is different every time.