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

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
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
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.
// 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 fromsignalOfanswer 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 |