RectSLPattern.kt
src/jvmMain/kotlin/afield/lang/rectsl/RectSLPattern.kt · 561 lines
@file:Suppress("unused")
package afield.lang.rectsl
import afield.audio.synth.KolakoskiReading
import afield.audio.synth.PatArc
import afield.audio.synth.PatEvent
import afield.audio.synth.PatPattern
import afield.audio.synth.PatQ
import afield.audio.synth.bipolar
import afield.audio.synth.cosine
import afield.audio.synth.euclid
import afield.audio.synth.isaw
import afield.audio.synth.kolakoski
import afield.audio.synth.num
import afield.audio.synth.nums
import afield.audio.synth.pat
import afield.audio.synth.perlin
import afield.audio.synth.quantise
import afield.audio.synth.rand
import afield.audio.synth.range
import afield.audio.synth.saw
import afield.audio.synth.segment
import afield.audio.synth.sine
import afield.audio.synth.square
import afield.audio.synth.texts
import afield.audio.synth.tri
RectSL's musical layer — the Tidal pattern language as scene structure
RectSL already had time: tracks, ->live birth life, ->stagger-time, ->split. What it
did not have was meter — a shared clock, subdivision, and (the one that actually matters)
any way for repetition n+1 to differ from repetition n. The array primitives distribute
copies in space and every copy plays the same clip. That is the difference between a loop and
a groove, and it is exactly the gap afield.audio.synth's pattern language fills.
The two languages turn out to be the same shape underneath, which is why this bridge is thin rather than a translation layer:
RectSL interface Track<out T> { fun sample(t: Double): T }
Pattern class PatPattern<A> { fun events(arc: PatArc): List<PatEvent<A>> }
Both are pure functions of time, queryable out of order, with no simulation state —
PatContractTest's purity and query-order properties assert it on one side, and RectSL's
closed-form tracks (the reason HeadingTrack can recover velocity by finite difference)
assert it on the other. So composing them needs no scheduler and no frame loop: a pattern
is a track (PatTrack), and an event is a life window (patArray).
What lives here
| § | words |
|---|---|
| clock | ->cps ->tempo ->beats-per-cycle ->duration-cycles beats cycles cycle-secs |
| pat | pat and its per-event context t dur cyc step atom param param? |
| patterns as values | mini sine cosine saw isaw tri square rand perlin nums texts segment pat-range quantise bipolar euclid kolakoski kolakoski-gaps |
| variation | ->every ->off ->jux ->degrade ->swing ->press ->iter ->chunk |
| layout | strip ->euclid ->kolakoski |
| out | pat-events |
Why a separate file
RectSL.kt is already 4.6k lines, and this layer is the only part of the language that depends
on afield.audio.synth. Keeping the dependency in one file means the rest of RectSL still
compiles and reads without knowing the pattern language exists. What it reaches back into
RectSL.kt for is a short list: RectLangVm.word/RectLangVm.mutator
(so the musical words register through the same one path as every other word),
RectLangVm.loopStack and LoopFrame.pat (so i/n/f keep working inside a pat body),
SceneBuilder.mapScalarTracks, and SceneBuilder.cps.
@see afield.audio.synth.PatPattern the event model @see patArray the keystone: one template copy per event @see PatTrack the keystone the other way: a pattern sampled as a track
Values
data class VPat(val pattern: PatPattern<String>) : Value
An event pattern on the RectSL stack — a rhythm. Holds PatPattern<String>, the same type
pat("bd*4") produces, so the whole mini-notation surface (polymeter, Euclid, <a b>,
? degrade, . grouping) arrives intact rather than being re-parsed here.
data class VSig(val signal: PatPattern<Double>) : Value
A numeric pattern on the RectSL stack — a signal. Separate from VPat because the round trip
through texts()/nums() formats to nine decimal places, and a value that is about to
become a track should not be quantised on the way. Coerces to a PatTrack wherever RectSL
wants a Track<Double>.
A pattern as a track
data class PatTrack
A pattern sampled as a RectSL scalar track. t is scene seconds; cps converts to the
pattern's cycles.
The query is a microscopically wide arc rather than an instant, because PatArc requires
end > start — a pattern has no notion of a zero-width query, and §1 of PATTERN_LANGUAGE.md
explains why (an instant could be missed; an arc never is). The width is one part in
SAMPLE_DENOM, which is a microcycle: far below a frame at any sane tempo, and small enough
that a continuous signal's arc-midpoint convention returns essentially the value at t.
Two behaviours of the sampled result:
- A rest is a zero, not a hold. "1 ~ 1" returns fallback in the middle third, because
the pattern genuinely has no event there. Holding would require remembering the previous
sample, which is state, which would break the purity the rest of this bridge depends on.
hold (the word) is the pure way to ask for sample-and-hold instead.
- A stack resolves to its topmost layer. stack(a, b) yields a then b for the same
arc; a scalar track has to pick one, and the later layer is the one drawn on top.
data class PatTrack(
val signal: PatPattern<Double>,
val cps: Double,
val fallback: Double = 0.0
) : Track<Double> {
override val kind: TrackValueKind = TrackValueKind.Double
override fun sample(t: Double): Double
override fun sample(t: Double): Double {
val start = PatQ.fromDouble(t * cps, SAMPLE_DENOM)
val arc = PatArc(start, start + PatQ.of(1L, SAMPLE_DENOM))
return signal.events(arc).lastOrNull()?.value ?: fallback
}
companion object
companion object {
const val SAMPLE_DENOM = 1_000_000L
One microcycle — the width of the instant a track sample stands in for.
data class PatHoldTrack
Sample-and-hold: like PatTrack but a rest keeps the previous event's value instead of
falling back to zero. Pure, not stateful — it looks backwards from t over a bounded
window and takes the latest event that has already started, so it still answers any time in
any order with the same value.
data class PatHoldTrack(
val signal: PatPattern<Double>,
val cps: Double,
val fallback: Double = 0.0,
val lookBackCycles: Long = 2L
) : Track<Double> {
override val kind: TrackValueKind = TrackValueKind.Double
override fun sample(t: Double): Double
override fun sample(t: Double): Double {
val now = t * cps
val here = PatQ.fromDouble(now, PatTrack.SAMPLE_DENOM)
val from = PatQ.fromDouble(now - lookBackCycles, PatTrack.SAMPLE_DENOM)
val arc = PatArc(from, here + PatQ.of(1L, PatTrack.SAMPLE_DENOM))
return signal.events(arc)
.filter { it.arc.start <= here }
.maxByOrNull { it.arc.start }
?.value
?: fallback
}
}
internal fun VSig.asTrack(cps: Double): Track<Double> = PatTrack(signal, cps)
A signal read as a track.
internal fun numericPatternTrackOrNull(text: String, cps: Double): Track<Double>?
A bare string read as a numeric rhythm — "0 1 2 3" ->x — or null if it is not one.
The guard is the whole design of this function. A String reaching a track slot has always
been an error, so accepting mini-notation there only adds meaning; but accepting any string
would turn "viridis" ->x (a colormap name in the wrong place) from a clear error into a
silent zero. So the text has to actually contain a number before it counts as a pattern, and
anything else falls through to the error message it has always produced.
The order of that guard matters, and used not to. Asking pat first and treating any
failure as "not a pattern" hides bugs in the mini-notation machinery behind a message about
the caller's string: a crash in pat's source-location helper made "0 1 2 3" ->x inside a
live Field box report "a String is read as mini-notation only when it contains numbers".
So the cheap textual question is asked first, and once the text looks like a rhythm any
failure to read it is reported as itself. The semantic decision is unchanged — a numeric
atom is still what makes it a pattern, so "magma256" is still not one.
internal fun numericPatternTrackOrNull(text: String, cps: Double): Track<Double>? {
if (!DIGIT.containsMatchIn(text)) return null
val pattern: PatPattern<String>
val hasNumericAtom: Boolean
try {
pattern = pat(text)
hasNumericAtom = pattern.events(PatArc(PatQ.ZERO, PatQ.ONE))
.any { it.value.trim().toDoubleOrNull() != null }
} catch (t: Throwable) {
throw RectLangException(
"\"$text\" has digits in it, so it was read as a numeric rhythm, but mini-notation " +
"could not parse it: ${t.message ?: t.toString()}"
)
}
return if (hasNumericAtom) PatTrack(pattern.nums(), cps) else null
}
private val DIGIT = Regex("""\d""")
internal fun VPat.asTrack(cps: Double): Track<Double> = PatTrack(pattern.nums(), cps)
A rhythm read as a track: its atoms are read as numbers, so "0 0.5 1" is a stepped ramp and
"bd sd" — whose atoms are not numbers — is silence rather than an error, the same reading
afield.audio.synth.nums gives.
The per-event context a pat body sees
data class PatEventContext
The musical identity of one event, readable inside a pat body through t, dur, cyc,
step, atom and param. Times are already in scene seconds, converted once here, so
nothing downstream has to remember the tempo.
data class PatEventContext(
/** Onset, in scene seconds. */
val t: Double,
/** Length, in scene seconds — the event's *whole*, not the fragment a query happened to see. */
val dur: Double,
/** Which cycle the onset falls in. */
val cycle: Int,
/** Index of this event within its own cycle, counting from 0. */
val step: Int,
/** The event's mini-notation value: `"bd"`, `"808:4"`, `"c'maj7"`. */
val atom: String,
/** Named parameters carried on the event — `gain`, `pan`, `n`, `note`, … */
val params: Map<String, String>
)
Installation
internal fun RectLangVm.installPatternWords()
internal fun RectLangVm.installPatternWords() {
installClockWords()
installPatternValueWords()
installPatWord()
installVariationWords()
installLayoutWords()
}
§1 Clock
private fun RectLangVm.installClockWords() {
// Scene configuration reads its argument as the *following token*, the way `->duration 4`
// and `->fps 20` already do, rather than off the stack. Keeping the clock in the same shape
// as the rest of the scene header is worth more than consistency with the value words.
mutator("cps") { vm -> vm.builder().setCps(vm.consumeNumber("->cps")) }
mutator("tempo") { vm -> vm.builder().setTempo(vm.consumeNumber("->tempo")) }
mutator("beats-per-cycle") { vm -> vm.builder().setBeatsPerCycle(vm.consumeNumber("->beats-per-cycle")) }
mutator("duration-cycles") { vm ->
val b = vm.builder()
b.duration = vm.consumeNumber("->duration-cycles") / b.cps.coerceAtLeast(1e-9)
}
word("beats") { vm ->
val b = vm.builder()
vm.push(VNum(vm.popNum() / b.cps.coerceAtLeast(1e-9) / b.beatsPerCycle.coerceAtLeast(1e-9)))
}
word("cycles") { vm -> vm.push(VNum(vm.popNum() / vm.builder().cps.coerceAtLeast(1e-9))) }
word("cycle-secs") { vm -> vm.push(VNum(1.0 / vm.builder().cps.coerceAtLeast(1e-9))) }
word("scene-cycles") { vm ->
val b = vm.builder()
vm.push(VNum(b.duration * b.cps))
}
}
internal fun SceneBuilder.setTempo(bpm: Double)
->tempo and ->beats-per-cycle are order-independent on purpose: a scene that says
->tempo 120 ->beats-per-cycle 3 and one that says them the other way round must agree, so
the bpm is remembered and cps re-derived whenever either changes. Setting ->cps directly
forgets the bpm, because a directly-stated cps is not a tempo divided by anything.
internal fun SceneBuilder.setTempo(bpm: Double) {
this.bpm = bpm
cps = bpm / 60.0 / beatsPerCycle.coerceAtLeast(1e-9)
}
internal fun SceneBuilder.setBeatsPerCycle(n: Double)
internal fun SceneBuilder.setBeatsPerCycle(n: Double) {
beatsPerCycle = n.coerceAtLeast(1e-9)
bpm?.let { cps = it / 60.0 / beatsPerCycle }
}
internal fun SceneBuilder.setCps(value: Double)
internal fun SceneBuilder.setCps(value: Double) {
cps = value.coerceAtLeast(1e-9)
bpm = null
}
§2 Patterns as stack values
source — 61 lines, from line 288
private fun RectLangVm.installPatternValueWords() {
word("mini") { vm -> vm.push(VPat(pat(vm.popString()))) }
word("sine") { vm -> vm.push(VSig(sine())) }
word("cosine") { vm -> vm.push(VSig(cosine())) }
word("saw") { vm -> vm.push(VSig(saw())) }
word("isaw") { vm -> vm.push(VSig(isaw())) }
word("tri") { vm -> vm.push(VSig(tri())) }
word("square") { vm -> vm.push(VSig(square())) }
word("rand") { vm -> vm.push(VSig(rand(vm.popNum().toInt()))) }
word("perlin") { vm -> vm.push(VSig(perlin(vm.popNum().toInt()))) }
word("nums") { vm -> vm.push(VSig(vm.popPat("nums").nums())) }
word("texts") { vm -> vm.push(VPat(vm.popSig("texts").texts())) }
word("segment") { vm ->
val n = vm.popNum().toInt().coerceAtLeast(1)
vm.push(VSig(vm.popSig("segment").segment(n)))
}
// `range` is already the loop word, so the pattern remap needs its own spelling.
word("pat-range") { vm ->
val hi = vm.popNum()
val lo = vm.popNum()
vm.push(VSig(vm.popSig("pat-range").range(lo, hi)))
}
word("quantise") { vm ->
val step = vm.popNum()
vm.push(VSig(vm.popSig("quantise").quantise(step)))
}
word("quantize") { vm ->
val step = vm.popNum()
vm.push(VSig(vm.popSig("quantize").quantise(step)))
}
word("bipolar") { vm -> vm.push(VSig(vm.popSig("bipolar").bipolar())) }
word("euclid") { vm ->
val rotation = vm.popNum().toInt()
val steps = vm.popNum().toInt()
val pulses = vm.popNum().toInt()
vm.push(VPat(euclid(pulses, steps, rotation).onsetsOnly()))
}
// The Kolakoski pair. Two words rather than one word and a mode flag, because RectSL has no
// enums and a bare 0/1 on the stack would say nothing at the call site. The alphabet stays at
// {1, 2} here; the Kotlin side takes it as a parameter.
word("kolakoski") { vm ->
val rotation = vm.popNum().toInt()
val steps = vm.popNum().toInt()
vm.push(VPat(kolakoski(steps, KolakoskiReading.RUNS, rotation = rotation).onsetsOnly()))
}
word("kolakoski-gaps") { vm ->
val rotation = vm.popNum().toInt()
val steps = vm.popNum().toInt()
vm.push(VPat(kolakoski(steps, KolakoskiReading.GAPS, rotation = rotation).onsetsOnly()))
}
// Sample-and-hold, the pure kind: `"0 ~ 1" mini nums hold ->rot` holds through the rest.
word("hold-pat") { vm ->
vm.push(VTrack(PatHoldTrack(vm.popSig("hold-pat"), vm.builder().cps)))
}
}
Keep only the true steps of a boolean pattern, as "x" events — a rhythm, not a mask.
euclid returns a step per slot, on and off alike; pat wants one copy per hit, and an
off-step is the absence of a hit rather than a hit valued false.
private fun PatPattern<Boolean>.onsetsOnly(): PatPattern<String> =
PatPattern { arc -> events(arc).filter { it.value }.map { it.withValue("x") } }
§3 pat: the time-array primitive
private fun RectLangVm.installPatWord() {
word("pat") { vm -> vm.patArray() }
word("t") { vm -> vm.push(VNum(vm.patContext("t").t)) }
word("dur") { vm -> vm.push(VNum(vm.patContext("dur").dur)) }
word("cyc") { vm -> vm.push(VNum(vm.patContext("cyc").cycle.toDouble())) }
word("step") { vm -> vm.push(VNum(vm.patContext("step").step.toDouble())) }
word("atom") { vm -> vm.push(VStr(vm.patContext("atom").atom)) }
word("param") { vm ->
val key = vm.popString()
vm.push(VNum(vm.patContext("param").params[key]?.toDoubleOrNull() ?: 0.0))
}
word("param?") { vm ->
val key = vm.popString()
vm.push(VNum(if (vm.patContext("param?").params.containsKey(key)) 1.0 else 0.0))
}
word("atom?") { vm ->
val want = vm.popString()
vm.push(VNum(if (vm.patContext("atom?").atom == want) 1.0 else 0.0))
}
}
internal fun RectLangVm.patArray()
`template pattern quote -- selection` — reproduce a template once per event, each copy
living for exactly that event's window.
This is the keystone. RectSL's existing array primitives (array, radial, grid,
phyllotaxis) reproduce a subexpression indexed by ordinal and distribute the copies in
space; pat reproduces it indexed by rhythm and distributes the copies in time. The mapping
is exact rather than approximate, which is the whole reason this is worth doing: a PatEvent
carries an onset and a real duration (its whole, §4b), and ->live birth life wants exactly
an onset and a duration. An event is a life window.
Everything the mini-notation can say therefore arrives for free — polymeter {a b c}%8,
Euclid bd(3,8), per-cycle alternation <a b>, ? degrade, . grouping, @ elongation —
without RectSL parsing any of it.
Three details that are decisions rather than mechanics:
- Events come from
eventSequence, not a wideevents()query. A note crossing a bar line comes back from two consecutive cycle queries, clipped both times;eventSequenceyields only the fragment carrying the onset and widens it back to its whole (§4b). Using the wide query instead would be almost right and would silently spawn two half-length copies of any event that straddles a bar — the visual form of the note struck twice. ->liveis applied after the body runs, so the body authors the copy's flight in local time (0is its own birth) exactly as->split's shards do. Authoring in local time is what lets one template become a stream.- The body's own spawn is gated too. Anything the body creates — a nested
array, a->split, a group of children — is captured and given the same window, sopatcomposes with the rest of the language instead of sitting beside it.
body — 55 lines, from line 411
internal fun RectLangVm.patArray() {
val mods = popQuote()
val pattern = popPat("pat")
val b = builder()
val cps = b.cps.coerceAtLeast(1e-9)
val cycles = b.duration * cps
val events = pattern.onsetsWithin(cycles)
val template = popTemplateRef("pat")
// A rhythm with no events shows nothing. This differs from `0 array`, which leaves its
// template visible, and the difference is deliberate: `0 array` is a degenerate call nobody
// writes, whereas an all-rests pattern, a fully degraded one, or one whose events all fall
// past the scene's end are ordinary things to write — and each would otherwise leave a
// stray unexplained rect sitting at the origin.
if (events.isEmpty()) {
b.withActive(template) { b.multiplyAlpha(constantTrack(0.0)) }
push(VSelection(emptyList()))
return
}
val refs = b.instantiateArray(template, events.size)
// `step` counts within a cycle, so it restarts at each bar line rather than running on.
var cycleOfPrevious = Int.MIN_VALUE
var step = 0
refs.forEachIndexed { ordinal, ref ->
val event = events[ordinal]
val onsetCycles = event.arc.start.toDouble()
val cycle = kotlin.math.floor(onsetCycles).toInt()
if (cycle != cycleOfPrevious) {
cycleOfPrevious = cycle
step = 0
}
val birth = onsetCycles / cps
val life = ((event.extent.end.toDouble() - onsetCycles) / cps).coerceAtLeast(MIN_EVENT_SECONDS)
val context = PatEventContext(
t = birth,
dur = life,
cycle = cycle,
step = step,
atom = event.value,
params = event.annotations
)
step += 1
val spawned = b.captureNewNodes {
withSelected(ref) { withPatLoop(ordinal, events.size, context) { evalBlock(mods.tokens) } }
}
(listOf(ref) + spawned).forEach { node -> b.withActive(node) { b.live(birth, life) } }
}
push(VSelection(refs))
}
Every event that starts within the first cycles cycles, in playing order, each yielded
once and widened to its whole.
This is PatPattern.eventSequence's rule — take only the fragment carrying the onset, widen
it back to the note's real extent (§4b) — applied over a bounded span rather than forever.
The bound is the reason it is written out rather than delegated: eventSequence is an
infinite generator that simply advances through cycles which produce nothing, so
takeWhile { it.start < cycles } on a pattern with a silent cycle never terminates. A scene
has a duration, so asking cycle by cycle up to it is both correct and finite.
private fun PatPattern<String>.onsetsWithin(cycles: Double): List<PatEvent<String>> {
val out = ArrayList<PatEvent<String>>()
val lastCycle = kotlin.math.ceil(cycles).toLong()
var cycle = 0L
while (cycle < lastCycle) {
events(PatArc(PatQ.of(cycle), PatQ.of(cycle + 1)))
.filter { it.hasOnset && it.arc.start.toDouble() < cycles }
.sortedBy { it.arc.start.toDouble() }
.forEach { event -> out += if (event.whole != null) event.copy(arc = event.whole) else event }
if (out.size > MAX_PAT_EVENTS) {
throw RectLangException(
"pat: this rhythm asks for more than $MAX_PAT_EVENTS copies over ${"%.1f".format(cycles)} " +
"cycles. Slow the pattern, shorten the scene, or raise ->cps."
)
}
cycle++
}
return out
}
A guard, not a limit anyone should hit: pat instantiates one scene node per event, and a
fast pattern over a long scene can ask for more than a scene can hold. Blowing past this
throws rather than quietly truncating, because a rhythm that stops halfway through the scene
looks like a bug in the pattern rather than a cap being reached.
private const val MAX_PAT_EVENTS = 20_000
A zero-length event would give a copy an empty gate and make it invisible.
private const val MIN_EVENT_SECONDS = 1e-6
Stack helpers
internal fun RectLangVm.popPat(word: String): PatPattern<String>
Accepts a VPat, or a bare string, which is auto-parsed — "bd*4" '( … ) pat reads best.
internal fun RectLangVm.popPat(word: String): PatPattern<String> = when (val value = pop()) {
is VPat -> value.pattern
is VStr -> pat(value.x)
is VSig -> value.signal.texts()
else -> throw RectLangException("$word expected a pattern or a mini-notation String, found ${value.patDescribe()}")
}
internal fun RectLangVm.popSig(word: String): PatPattern<Double>
Accepts a VSig, a VPat (read as numbers), or a constant.
internal fun RectLangVm.popSig(word: String): PatPattern<Double> = when (val value = pop()) {
is VSig -> value.signal
is VPat -> value.pattern.nums()
is VStr -> pat(value.x).nums()
is VNum -> afield.audio.synth.constant(value.x)
else -> throw RectLangException("$word expected a pattern or Number, found ${value.patDescribe()}")
}
private fun Value.patDescribe(): String = when (this) {
is VPat -> "Pattern"
is VSig -> "Signal"
is VNum -> "Number"
is VStr -> "String"
else -> this::class.simpleName ?: "Value"
}
internal fun RectLangVm.withPatLoop
internal fun RectLangVm.withPatLoop(
ordinal: Int,
count: Int,
context: PatEventContext,
block: () -> Unit
) {
loopStack.addLast(LoopFrame(index = ordinal, count = count, ordinal = ordinal, pat = context))
try {
block()
} finally {
loopStack.removeLast()
}
}
internal fun RectLangVm.patContext(word: String): PatEventContext
internal fun RectLangVm.patContext(word: String): PatEventContext =
loopStack.lastOrNull()?.pat
?: throw RectLangException("$word is only available inside a `pat` body")
§4 Variation: the combinators that make repetition differ
private fun RectLangVm.installVariationWords() = installVariationWordsImpl()
§5 Layout
private fun RectLangVm.installLayoutWords() = installLayoutWordsImpl()