Skip to content

A pattern as a function generator

A pattern says what happens and when. marf(...) uses patterns to make values move over time: as a sequence, an envelope, an LFO, or a trajectory through stored material.

The idea comes from the Buchla 248 Multiple Arbitrary Function Generator. The 248 keeps four things on separate patch points:

  • the material stored in a bank of stages,
  • the address that chooses which stage to read,
  • the time each stage is given,
  • and the function drawn while a stage is active.

Here all four are ordinary patterns. slow, rev, every, euclid, degradeBy and struct already work on all four, so this layer adds almost no operators of its own. In the figures below, a dashed line marks each stage boundary and the polyline is the reader's output, sampled through the same calls you would use (MarfGallery draws them).


A stage, and the shape of it

A stage has a destination, a length, and a curve. It starts from wherever the output already was, so successive stages can form an envelope. The curve determines what happens next: a smooth ramp moves from that starting value, while a hold can jump immediately to its destination.

rampTo(0.9, duration = 0.5) // slew to 0.9 over half a cycle
holdAt(0.4, duration = 2.0) // step to 0.4 and stay for two
oscTo(0.7, duration = 1.0, cycles = 3) // three oscillations toward 0.7 and back
curveTo(0.2, duration = 0.25, amount = 4.0)
stage(0.5, duration = 1.0, curve = MarfCurve.Smooth)

A curve is a shaping function on the stage's own local phase, 0..1. The reader turns it into an output with from + (to - from) * shape(phase), so a curve never needs to know which values it is moving between.

eight stage curves, one per lane, each moving from 0 to 1 and back

hold is the sample-and-hold case: it arrives at once and stays, which is the step in the top lane. step:5 is a staircase in five treads. osc takes a whole number of oscillations and returns to where it started, so a stage can be an LFO burst whose rate is set by its own length rather than by a frequency in hertz. The declarative family is data rather than lambdas, so a stage can be printed, compared and stored; MarfCurve.Shape("chirp") { p -> ... } is there for when that does not matter.


Duration is one of the patterns

Stage length is patterned like everything else, and the stages that come out are not on a grid.

val env = marf(
    value = pat("<0 .5 .2 1>").nums(),
    duration = pat("<1 4 .25 2>").nums(),
    curve = curves("<linear exp hold osc>"),
    fields = MarfFieldClock.STAGES,
)

three lanes: equal stages, then patterned durations, then patterned curves

The middle lane's four stages are one, four, a quarter and two cycles long. Those numbers are exact: stage time accumulates in PatQ rationals, never in Double, so a reader is still phase-locked to the bar after ten thousand stages.

Each field is read at the instant the stage begins, through the same latch the parameter algebra uses for gain(...) at a note onset. Then the next stage begins where this one ends. The reader's own traversal generates its clock, which is why the periods drift against each other: give the address a period of six and the duration a period of four and the pair repeats every twelve.

A field pattern with rests holds rather than resetting. When nothing is sounding at a stage boundary, that field keeps the value the previous stage had, so struct and degradeBy on an address become how long an address is held for.


Banks, and the readers over them

A bank is stored material. It has no position, no direction and no clock; all of that belongs to the readers, which is the property worth borrowing from the hardware.

val b = marfBank(
    rampTo(0.1, 1.0),
    rampTo(0.9, 0.5),
    holdAt(0.4, 2.0),
    oscTo(0.7, 1.0, cycles = 3.0),
    curveTo(0.2, 0.25, amount = 4.0),
)

val a = b.read(pat("<0 1 2 1 3 4>").nums(), fields = MarfFieldClock.STAGES)
val c = b.read(pat("<0 2 4 1 3>").nums().slow(3), fields = MarfFieldClock.STAGES)
val d = b.read(pat("<0 4>").nums(), speed = constant(4.0), fields = MarfFieldClock.STAGES)

three readers over one bank, following three different trajectories

Three voices reading one score. slow(3) holds each address for three stages; speed = 4 divides every duration, so the third lane's stages are a quarter as long as the bank wrote them. The bank is the same object in all three lanes; only the trajectory over it differs.

fields = MarfFieldClock.STAGES is what makes "holds each address for three stages" exactly true. The bank's own durations are uneven, so under a CYCLES reading each address would be latched at whatever wall-clock instant a stage happened to start at, and the readers would not walk the bank one slot at a time at all. The default, AUTO, picks STEPS for these addresses, which also walks them one slot per stage. Which clock the fields are read in explains the three readings.


Reading a bank in any order

Once the address is a pattern, sequential reading is one case among many, and the pattern algebra does the work that a list of traversal modes would otherwise have to.

four readers over one bank with four different address patterns

val steps = MarfFieldClock.STEPS
b.read(pat("0 1 2 3").nums(), duration = constant(0.25), fields = steps) // forward
b.read(pat("0 1 2 3").nums().every(4) { it.rev() }, duration = constant(0.25), fields = steps) // periodic reversal
b.read(pat("0 1 2 3 2 1").nums(), duration = constant(1.0 / 6.0), fields = steps) // ping-pong
b.read(struct(euclid(5, 8), pat("0 4 2 3").nums()), duration = constant(0.125), fields = steps)

"0 1 2 3 2 1" is ping-pong; there is no direction = PINGPONG in this layer and there should not be one. In the second lane the first cycle's addresses read 3 2 1 0 and the next three read 0 1 2 3, which is what every(4) means. In the fourth, E(5,8) leaves three of the eight steps empty and the address holds across them.

One thing to know about mini-notation here: <a b c> carries one event per cycle, so rev() has nothing to reverse inside it. Reversal needs an address with steps inside the cycle, which is why these lanes use "0 1 2 3" and four stages a cycle.


Addresses between slots

The address is a PatPattern<Double>, not a pattern of integers. A fractional address evaluates the two stages it lies between over the same local phase and mixes their outputs, \( (1-\alpha)\,f(i) + \alpha\,f(i+1) \), blending their durations the same way. The entry value is shared between the two, so the output stays continuous across a boundary exactly as it does for a whole address.

a stepped address, a gliding one, and one that aliases to four slots

The bank stops being five slots and becomes a sampled one-dimensional family of functions.

A latched address is a sequence, however continuous the signal feeding it

The address is read once per stage, so a reader with periodic durations visits lcm(address period, stage period) / stage period distinct addresses and then repeats. The bottom lane above is sine().range(0, 4) against quarter-cycle stages: four addresses, 2, 4, 2, 0, forever. The middle lane is saw().slow(8) against the same stages, which is thirty-two of them and reads as a glide. An aperiodic address (perlin(), rand()) never repeats. Count them by rounding: an aliased address returns to within a few parts in 10⁴ and not bit-exactly, so a raw distinct() will tell you a four-value cycle has many more than four values in it.


A reader's two outputs: a signal and triggers

A reader gives you its stages in two forms: as a value that moves continuously, and as one event per stage. Both come from the same stages, so they always agree on where the boundaries fall.

A reader's continuous output is a signal in the sense of PatSignals.kt, so every combinator in the language already applies to it and the parameter algebra can sample it without knowing what it is.

// Two readers of one bank as two parameters of one voice.
val voice = pat("bd*8").with(gain(c.signal().range(0.25, 1.0))).with(pan(d.signal()))

// And the same temporal structure as pulses: one event per stage, spanning that stage.
a.triggers().respond(cps = 1.0) { event ->
    __m("pat").patHighlight(event)
}

voice is an ordinary pattern carrying gain and pan annotations, so every route in Sound: the Pattern → Audio Path takes it unchanged. triggers() gives one event per stage, and the event's whole is the stage's real span, so respond strikes it once and the callback sees the stage's true length. A trigger pattern thins that stream without touching the continuous output.

For graphics, sample directly. at(cycle) and atSeconds(seconds, cps) return a MarfSample, which carries the several notions of time that are live at once: the global cycle, the stage index, the time since this stage began, and that time normalised to 0..1.

:::fold the bank from above
val b = marfBank(
    rampTo(0.1, 1.0),
    rampTo(0.9, 0.5),
    holdAt(0.4, 2.0),
    oscTo(0.7, 1.0, cycles = 3.0),
    curveTo(0.2, 0.25, amount = 4.0),
)
:::endfold

val walk = b.read(pat("<0 1 2 3 4>").nums(), fields = MarfFieldClock.STAGES)
val slowWalk = b.read(pat("<0 2 4 1 3>").nums().slow(3), fields = MarfFieldClock.STAGES)

// One colour per slot of the bank.
val slotColors = listOf(
    vec(0.95, 0.45, 0.35, 1.0),
    vec(0.95, 0.80, 0.30, 1.0),
    vec(0.45, 0.80, 0.45, 1.0),
    vec(0.35, 0.65, 0.95, 1.0),
    vec(0.75, 0.50, 0.95, 1.0),
)

// A reader against time over eight cycles: a new line at each stage, in the colour of its slot.
fun lane(r: MarfReader<Double>, top: Double): List<FLine> {
    val out = mutableListOf<FLine>()
    var stage = -1L
    var line = fline {}
    for (i in 0..1600) {
        val s = r.at(i / 200.0)
        val px = i * 600.0 / 1600
        val py = top + (1.0 - s.value) * 80.0
        if (s.stageIndex != stage) {
            stage = s.stageIndex
            line = fline {
                stroked = true
                thickness = 2.0
                color = slotColors[s.address.toInt()]
            }
            line.moveTo(px, py)
            out += line
        } else line.lineTo(px, py)
    }
    return out
}

// Cycle boundaries, shared by both lanes.
val grid = (0..8).map { c ->
    fline {
        moveTo(c * 75.0, 0.0)
        lineTo(c * 75.0, 200.0)
        stroked = true
        thickness = 0.5
        color = vec(1.0, 1.0, 1.0, 0.2)
    }
}

lines(grid + lane(walk, 0.0) + lane(slowWalk, 120.0), w = 640.0, h = 220.0, fitToSize = true)

two lanes over eight cycles, each a line coloured by the bank slot it is reading: the top lane passes through all five slots, the bottom one through two

walk on top, slowWalk below, each stage coloured by the slot it reads. Both take their stage lengths from the bank, so a slot lasts as long in one lane as in the other: the hold (green) is two cycles wherever it appears. walk moves to the next slot every stage and has been through all five by cycle 4.75. slowWalk's address changes every three cycles but is read only when a stage begins, so in eight cycles it reaches two slots, and the oscillation (blue) never shows up in it.

A curve is written against stagePhase and never has to reconstruct it, which makes the comparison with a shader a useful one to keep: a fragment shader computes over uv, a stage computes over its own local phase, inside a structure the pattern algebra laid out.


A stage's value need not be a number

A stage can move toward a vector as easily as toward a number. All a reader ever does with two values is move part of the way from one to the other, and MarfSpace<T> supplies what that takes for a type T: sub for the difference between them, scale for the fraction of it, add to get there. The curve is still a single number from 0 to 1. It says how far through the stage you are, and that fraction is the same for every component of the vector.

val path = marfBankIn(
    MarfVector3f,
    stageTo(Vector3f(0f, 0f, 0f), 1.0),
    stageTo(Vector3f(1f, 0f, 0f), 0.5, MarfCurve.Smooth),
    stageTo(Vector3f(1f, 1f, 0f), 1.5, MarfCurve.Exp(3.0)),
    stageTo(Vector3f(0f, 0.25f, 1f), 1.0, MarfCurve.Osc(2.0)),
).read(pat("<0 1 2 3 2 1>").nums(), fields = MarfFieldClock.STAGES)

path.at(3.5).value // a Vector3f
path.signal { it.x.toDouble() } // any component, as an ordinary signal

one Vector3f reader drawn as its three components, sharing one stage structure

Three lanes, one reader. The dashed boundaries fall in the same places in all three, because there is one stage structure carrying three values; the osc:2 stage at cycle 3 puts its double bump into every component at once, and each component arrives at a different place because the stage targets differ. signal { … } is how a vector reader reaches the rest of the language, which is scalar nearly everywhere: each component is a PatPattern<Double>, so gain, range and segment apply to it unchanged.

marf(...) and marfBank(...) are the scalar spellings; marfIn and marfBankIn take a space. Instances for Vector2f, Vector3f and Vector4f ship with it.

A space whose arithmetic writes into its arguments, an in-place a.add(b) on a mutable vector, would rewrite the bank's material as it is read. marfBankIn tries each operation once on the targets and refuses such a space by name; write yours with the out-parameter forms, as the shipped instances do (a.add(b, Vector3f())).

MarfSpace is a vector space, which rules out the types that are not one. A quaternion interpolates by slerp and a hue wraps, so neither is expressible as add-and-scale and neither has an instance here. Those want a reader whose blend is its own function.


An event stream as a continuous function

Three of these need no bank and no reader. A discrete pattern is four events a quarter-cycle apart and nothing samples it in between; these give it a value everywhere, so it can be a filter cutoff, an x coordinate or a radius.

pat("0 .5 1 .25").nums().hold() // sample and hold
pat("0 .5 1 .25").nums().ramp() // linear between onsets
pat("0 .5 1 .25").nums().interp(MarfCurve.Smooth) // any curve between onsets

hold, ramp and interp over one four-step pattern

Unlike segment, these do not change the rhythm; they remove it. The result is analog, with no onsets at all, and segment(n) is how you get onsets back.

hold() and interp(MarfCurve.Hold) are different, and both are wanted. A segment in interp is named by both its ends, so arriving at once means arriving at the next onset's value: a staircase one step early. A stage in a reader is named only by its destination, so the same curve there is an ordinary sample-and-hold.


Which clock the fields are read in

A field is a pattern, and a pattern can mean two different things here: a sequence of steps, or a value you sample, one that exists at every instant the way a modular synthesizer's control voltage does. MarfFieldClock says which you meant.

the same two fields under both readings: a stepped address collapses under CYCLES, and a saw freezes under STEPS

// A stepped field. CYCLES cannot reach the `1`; STEPS walks 0 1 3 1.
b.read(pat("<0 3> 1").nums(), duration = constant(0.25), fields = MarfFieldClock.CYCLES)
b.read(pat("<0 3> 1").nums(), duration = constant(0.25), fields = MarfFieldClock.STEPS)

// A continuous field. CYCLES glides; STEPS samples it once per cycle, so it sticks.
b.read(saw().range(0.0, 4.0), duration = constant(0.25), fields = MarfFieldClock.CYCLES)
b.read(saw().range(0.0, 4.0), duration = constant(0.25), fields = MarfFieldClock.STEPS)

Read the addresses printed in each lane. The top pair is "<0 3> 1" with stages a cycle long: under CYCLES it reads 0 3 0 3 and the 1 is never reached at all, because one instant a cycle lands on the alternation every time; under STEPS it reads 0 1 3 1, which is what was written. The bottom pair is saw() with quarter-cycle stages: under CYCLES it walks 0 1 2 3 through the bank, and under STEPS it reads 0 0 0 0 — stuck, because one sample per cycle of something that repeats every cycle is a single number.

Neither reading suits both patterns, so the default, AUTO, chooses per reader.

Why the two pairs use different stage lengths

What CYCLES gives you depends on how long a stage is relative to the pattern's own steps. The collapse in the top pair needs cycle-length stages to show — at quarter-cycle stages CYCLES samples "<0 3> 1" four times a cycle and does reach the 1. The bottom pair needs sub-cycle stages for the opposite reason: a saw sampled at integer instants is a constant under every clock.

AUTO, the default

A reader resolves AUTO when it is built and reports the answer in fieldClock, so nothing downstream ever sees AUTO. It asks one question of the leading field (value for marf(...), address for bank.read(...)): does this pattern have a discrete event at all?

  • Yes → STEPS. "<0 3> 1", "0 1 2 3", struct(euclid(5, 8), …) and "0 ~ .8 ~" are written as sequences, so they are read as the sequences they are, one step per stage.
  • No → CYCLES. saw(), sine() and anything else built from signalOf answer a query with one analog event spanning it, so there are no steps to walk. Walking one anyway samples it once per cycle, and a cycle-periodic signal sampled once per cycle is a constant: saw() as an address would sit still.

So a field is a sequence when you write steps and a sampled value when you write a signal. Name a clock explicitly whenever you want the other reading of the same pattern.

The three readings in detail:

CYCLES reads each field at the instant the stage starts. This is the control-voltage reading, and the one the hardware uses. Every pattern means something in it: "0 .7 .2 1" is four values across the bar and sine() is a sweep across it. When the durations happen to sum to a cycle the reader locks to the bar and the fields line up with their own steps.

STAGES reads field k at cycle k, so one cycle of a field pattern is one stage and the fields become a sequence. duration = "<1 4 .25 2>" then gives stages of exactly those four lengths, and address = "<0 1 2 1 3 4>" walks the bank one slot per stage however long a stage takes. In this mode <…> is the idiom, because one instant is read per cycle.

STEPS is STAGES with each cycle subdivided: a field contributes one stage per step, and at least one per cycle. address = "<0 3> 1" is then the three-entry sequence 0 1 3 1 rather than the two-entry 0 3. Each field walks its own grid, so an address with two steps a cycle and a duration with one still advance a single entry each per stage.

A rest is a step, and it holds. "0 ~ .8 ~" is four stages reading 0 0 .8 .8, not two reading 0 .8. That is what makes struct(euclid(5, 8), …) on an address mean how long an address is held for rather than a sequence with the gaps squeezed out. The step grid is therefore every boundary an event draws in the cycle, its start and its end, not just its onsets.

STEPS and STAGES agree exactly whenever a field's events fill their cycle (every <…>, every slow(n), every signal), so for the patterns STAGES is usually written with, STEPS gives the same answer. They differ on a pattern that does not fill its cycle: "<0 3> ~" is 0 0 3 3 under STEPS, because the trailing rest is a held stage, and 0 3 0 3 under STAGES, which reads one instant per cycle and never sees it. And "at least one per cycle" is what preserves the slow(3) idiom: a field slowed by three has an event only every third cycle, and the two cycles in between are held stages, not absences.

When AUTO picks the wrong one

Name the clock. AUTO reads the shape of what you typed, and occasionally that is not what you meant: a stepped pattern you want read at each stage's start time, whichever step that lands on (CYCLES), or a signal you want read one value per cycle, one cycle per stage (STEPS). fields = MarfFieldClock.CYCLES and fields = MarfFieldClock.STEPS always win, and a reader tells you which clock it ended up with:

val r = b.read(pat("<0 3> 1").nums())
r.fieldClock // STEPS — AUTO saw discrete events
b.read(saw()).fieldClock // CYCLES — AUTO saw none

Watch for one case: AUTO decides from the leading field, so a stepped address with a saw() duration resolves to STEPS and that saw is then sampled once per step. One reader carries one clock. A field that wants the other reading wants its own reader, with signal() joining them.

What the sequence readings refuse

Under STAGES or STEPS, a field pattern whose structure the clock cannot see is rejected with an error that names the field, rather than half-read:

reader 'addr': the address pattern puts 2 steps inside cycle 0 (at 0, 1/2), and
MarfFieldClock.STAGES reads one instant per cycle — so only the step at 0 is ever
latched and the other 1 is discarded in silence. Either write the address as an
alternation, `<…>`, which is one step a cycle, or ask for the sequence you wrote
with fields = MarfFieldClock.STEPS.

The message says what would be lost and offers the two spellings that keep it.

A stack is the one case no clock reads. "<0 3>, 1" puts two layers on a single instant, a field latches to one value per stage, and the second layer is unreachable, so the sequence readings refuse a stack too. CYCLES is exempt from both refusals: a field is a control voltage there, and reading one layer of a stack at an instant is what the parameter algebra does everywhere else.


Nesting a pattern inside a stage

A stage's continuous behaviour is a MarfCurve, and MarfCurve.Shape can close over anything, including another reader. What is not modelled is a nested pattern with a local temporal scope of its own inside the stage: the stage phase reaches a Shape as a Double, so a pattern read in there has no evaluation context to be slowed, reversed or thinned in.


Running it

The marf_demo starting point runs all of this, four boxes over one bank. Three of its boxes draw the figures above from live readers; the fourth plays one, sending the same bank through patSpatial so that a reader's output becomes a source's azimuth, its filter cutoff and its distance. dev_docs/MARF_DEMO.md is the note that goes with it.


Where the pieces are

Topic Source
The layer Marf.kt
Spaces for the linalg vectors MarfVectors.kt
The figures, and their checks MarfGallery.kt
A worked example, printed as text MarfSmokeTest.kt
The pattern language underneath Patterns & Rhythm