Skip to content

TidalPatterns.kt

src/jvmMain/kotlin/afield/audio/synth/TidalPatterns.kt · 2223 lines · cited by Patterns

@file:afield.runtime.completion.CompletionCategory("Patterns")

Field's pattern language: a compact text notation for musical structure, an exact algebra over cycles, and a scheduler that turns the result into events in seconds.

pat("bd [hh hh] sd") parses, the combinators on PatPattern transform, and scheduled(…), respond(…) and asNotes(…) are the three ways out. The middle stage is exact: a query asks a pattern about a PatArc, a half-open span of PatQ rational cycles, so a third of a bar sped up, reversed and slowed down again lands where it started. Seconds appear only at the boundary, and cps is where that conversion happens.

The notation follows Tidal in style rather than to the letter, and an atom is any string rather than a sample name, so a pattern can drive a synth voice, a property, an OSC send or a box launch as readily as a drum. The Field-facing API stays small and biased toward generic event streams rather than toward drum machines or sample triggers.

The design split is:

  • parse short textual pattern expressions with pat("...")
  • keep transforms exact in rational cycle space using PatQ / PatArc
  • expose scheduled events in seconds at the public boundary
  • allow atoms to mean anything, not just instruments or samples
  • bridge easily into the existing note-process / piano-roll system
  • also launch directly as a generic side-effecting event source

Read the surface from the outside in:

  1. Parse:

    1
    val hats = pat("bd [hh hh] sd")
    
  2. Transform in cycle space:

    1
    2
    3
    4
    5
    6
    7
    8
    val varied = pat("a b c d")
        .every(4) { it.rev() }
        .sometimes { it.fast(2) }
    
    val layered = stack(
        pat("bd ~ sd"),
        pat("[hh hh hh hh]").degradeBy(0.2)
    )
    

    Simultaneous events (chords in music contexts):

    1
    2
    val triad = pat("c, e, g")     // All three fire at once during the same beat
    val voicing = pat("<c e g>, <g b d>")  // Pick one set per cycle, stacked
    

    Arpeggiate over a chord or note sequence:

    1
    2
    3
    val bassArp = pat("bass bass bass").arp(60, 64, 67, speed = 4) // C major chord, 4 steps
    val upAndDown = pat("pad").arp(listOf("C3", "E3", "G3"), mode = ArpMode.UPDOWN, speed = 8)
    val randomArp = pat("keys").arp(48, 50, 52, 55, 57, mode = ArpMode.RANDOM, seed = 42)
    
  3. Schedule into seconds:

    1
    2
    3
    4
    5
    6
    7
    val events = pat("kick <snare rim>")
        .scheduled(cps = 2.0, startAt = 1.5)
        .take(8)
        .toList()
    
    events.first().start
    events.first().duration
    

    Or align directly to a shared musical clock:

    1
    2
    3
    4
    5
    6
    val master = musicClock()
    val sixteenth = master.subdivide(4.0)
    val upcoming = pat("bd [hh hh] sd")
        .scheduled(cps = 0.5, clock = sixteenth)
        .take(8)
        .toList()
    

    Euclidean suffixes attach directly to the preceding event or subgroup:

    1
    val groove = pat("[bd cp](../../../../../../reference/3,8) hh!2")
    

    A k or g in the same brackets asks for a Kolakoski figure over that many steps instead — k for the self-describing run-length reading, g for the gaps one. See PatKolakoski.kt:

    1
    2
    val selfDescribing = pat("bd(k16) hh*4")
    val dense = pat("bd(g16,3)")
    

    Repetition and density are distinct:

    1
    2
    val replicated = pat("bd!2 cp")
    val densified = pat("bd*2 cp")
    

    Elongation holds an event across later steps:

    1
    val held = pat("superpiano@3 superpiano")
    

    Curly-brace polymeters can also take an explicit subdivision:

    1
    2
    val polymeter = pat("{bd hh 808:4}%8")
    val layeredPolymeter = pat("{bd sd stab, cp arpy cr arpy}")
    

    Inside square brackets, | picks one alternative per cycle:

    1
    val randomChoice = pat("[bd*4|hh*12|cp*2]")
    

    Atom names can still carry sample-style parameter payloads:

    1
    val parameterized = pat("bd:(1,2,3) hh")
    

    By default, scheduled(...), respond(...), respondAhead(...), and asNotes(...) use the ambient PatClock from box context if one exists, otherwise they fall back to musicClock(). Pass clock = null if you want a zero-based schedule instead.

  4. Map atoms into existing note processes:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    val bass = pat("0 3 5 7").asNotes(cps = 2.0) { event ->
        val degree = event.value.toIntOrNull() ?: return@asNotes emptyList()
        listOf(
            note(
                pitch = 36 + degree,
                duration = event.duration * 0.95,
                start = event.start,
                label = event.value
            )
        )
    }
    
  5. Or respond directly, without assuming anything about the atom:

    1
    2
    3
    pat("flash spin fade").respond(cps = 1.0) { event ->
        box.display("atoms", "${event.value} @ ${event.start}")
    }
    

    If you want callback time slightly before event time, use lookahead:

    1
    2
    3
    pat("flash spin fade").respondAhead(ahead = 0.05, clock = musicClock()) { event ->
        box.display("atoms", "${event.value} in ${event.start - now}")
    }
    

    Scheduled events also retain the source span of the atom that produced them, so live debug tools can point back into the pattern text:

    1
    2
    3
    pat("bd [hh hh] sd").respond(cps = 2.0) { event ->
        __m("pat").patHighlight(event)
    }
    

You can also map the parsed atom text immediately:

data class Cue(val color: String, val gain: Float)

val cues = pat("calm bright danger") { atom ->
    when (atom) {
        "calm" -> Cue("#0af", 0.15f)
        "bright" -> Cue("#fff", 0.4f)
        else -> Cue("#f80", 0.8f)
    }
}

Important units and expectations:

  • start, end, and duration on scheduled events are in seconds.
  • cps still means cycles per second; that is the conversion boundary from cycle space into scheduled real time.
  • Most pattern combinators are pure transforms over cycle queries.
  • respond(...) is a coroutine event driver. respondAhead(...) lets the callback see future events before their target start.
  • asNotes(...) is the memoized bridge when you want the same future schedule to drive playback logic and piano-roll inspection without recomputing it.

Three companion files extend this one. Read them before writing a map { }, because one of them probably already has the combinator:

  • PatSignals.kt — continuous/signal patterns (sine, saw, perlin, rand) and the discretisers that turn them back into events (segment, struct, range, quantise).
  • PatAlgebra.kt — how two patterns of different types meet, and whose rhythm survives (|+| / |+ / +|, #, and named parameters via with(gain(...), pan(...))).
  • PatComposition.kt — arrangement (off, superimpose, jux, iter, linger, randcat, shuffle) and pitch (scale, chords, voiceLead).

respond(...) and respondAhead(...) are the hooks that assume nothing about the atom: it can drive a synth voice, a visual, a property, an OSC send, a sample or a box launch. pat(...) is the name to write against, and the one that will survive if the language underneath it changes.

package afield.audio.synth

import afield.document.graph.Box
import afield.runtime.kotlin.KotlinInterface
import afield.runtime.kotlin.PreparedSnippet
import afield.ui.output.RawDecoration
import afield.ui.output.decorationState
import afield.ui.output.locationForTextStringOnStack
import afield.app.diagnostics.logging.lprintln
import afield.document.graph.Marker
import java.math.BigInteger
import java.time.Instant
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.script.experimental.api.ResultValue
import kotlinx.coroutines.runBlocking

data class PatQ(val n: BigInteger, val d: BigInteger) : Comparable<PatQ>

An exact rational number of cycles. The reason a third of a bar survives being sped up, reversed and slowed down again and still lands where it started.

BigInteger over BigInteger, so there is no precision to lose and no bound on how finely a cycle can be subdivided. of and every operator normalize their result — the sign is carried on the numerator, then both terms are divided through by their gcd — so fractions built that way compare == when they are equal. The primary constructor does not normalize: it only rejects a zero denominator, so PatQ(2.toBigInteger(), 4.toBigInteger()) is a legal unnormalized value that compareTo calls equal to of(1, 2) while equals does not. Prefer of, and do not use a hand-constructed PatQ as a map key.

Seconds appear only at the boundary where something has to be scheduled or heard; everything inside the pattern algebra is in cycles and stays exact. fromDouble is the deliberate exception and is lossy — it truncates onto a fixed denominator — so it is for reading a slider, not for arithmetic whose result you intend to compare.

init { require(d != BigInteger.ZERO) { "Denominator cannot be 0" } }

companion object

val ZERO = PatQ(BigInteger.ZERO, BigInteger.ONE)

val ONE = PatQ(BigInteger.ONE, BigInteger.ONE)

fun of(n: Long, d: Long = 1) = normalize(BigInteger.valueOf(n), BigInteger.valueOf(d))

fun of(n: Int, d: Int = 1) = of(n.toLong(), d.toLong())

fun fromDouble(x: Double, denom: Long = 1_000_000L): PatQ

        fun fromDouble(x: Double, denom: Long = 1_000_000L): PatQ =
            of((x * denom).toLong(), denom)

        private fun normalize(n: BigInteger, d: BigInteger): PatQ {
            var nn = n
            var dd = d
            if (dd.signum() < 0) {
                nn = nn.negate()
                dd = dd.negate()
            }
            val g = nn.abs().gcd(dd)
            return PatQ(nn.divide(g), dd.divide(g))
        }
    }

    private fun norm(n: BigInteger, d: BigInteger) = Companion.normalize(n, d)

operator fun plus(other: PatQ) = norm(n * other.d + other.n * d, d * other.d)

operator fun minus(other: PatQ) = norm(n * other.d - other.n * d, d * other.d)

operator fun times(other: PatQ) = norm(n * other.n, d * other.d)

operator fun div(other: PatQ) = norm(n * other.d, d * other.n)

operator fun unaryMinus() = PatQ(n.negate(), d)

override fun compareTo(other: PatQ): Int = (n * other.d).compareTo(other.n * d)

fun toDouble(): Double

    fun toDouble(): Double =
        n.toBigDecimal().divide(d.toBigDecimal(), 12, java.math.RoundingMode.HALF_UP).toDouble()

override fun toString(): String = if (d == BigInteger.ONE) n.toString() else "$n/$d"

data class PatArc(val start: PatQ, val end: PatQ)

A half-open span of cycles, [start, end), and the only thing you may ask a PatPattern about.

Half-open is a real constraint rather than a convention: contains is t >= start && t < end, so consecutive arcs tile time without an event at a bar line belonging to both bars. That is what lets a scheduler walk cycle by cycle — the only thing it can do, since it cannot ask for all of time — without striking a note twice.

Both ends are exact (PatQ), and the transforms are the ones the time algebra needs: shift and scale for translation and tempo, dur for the length, and map to move both ends through one function — which is what PatEvent.mapTime is built on, so an event's PatEvent.whole travels with its arc instead of being rebuilt by hand. An empty or reversed arc is rejected on construction.

init { require(end > start) { "Arc end must be > start" } }

val dur: PatQ

    val dur: PatQ
        get() = end - start

fun contains(t: PatQ): Boolean = t >= start && t < end

fun shift(by: PatQ) = PatArc(start + by, end + by)

fun scale(by: PatQ) = PatArc(start * by, end * by)

inline fun map(f: (PatQ) -> PatQ): PatArc {

Applies f to both ends. Every time transform in this file should go through this rather than rebuilding a PatArc by hand, so that an event's PatEvent.whole can be carried along by the same map — see PatEvent.mapTime.

f may be decreasing (rev reflects time about the cycle), in which case the image is still the interval between the two results — just with the ends the other way round.

    inline fun map(f: (PatQ) -> PatQ): PatArc {
        val a = f(start)
        val b = f(end)
        return if (b > a) PatArc(a, b) else PatArc(b, a)
    }

fun intersect(other: PatArc): PatArc?

    fun intersect(other: PatArc): PatArc? {
        val s = if (start > other.start) start else other.start
        val e = if (end < other.end) end else other.end
        return if (e > s) PatArc(s, e) else null
    }

override fun toString(): String = "$start -> $end"

data class PatParseFrame

data class PatParseFrame(
    val className: String?,
    val methodName: String?,
    val fileName: String?,
    val lineNumber: Int
) {

override fun toString(): String

    override fun toString(): String = listOfNotNull(
        className,
        methodName?.let { ".$it" },
        fileName?.let { " ($it:$lineNumber)" }
    ).joinToString("").ifBlank { "frame:$lineNumber" }
}

data class PatParseContext

data class PatParseContext(
    val source: String,
    val lineHint: Int? = null,
    val stackTrace: List<PatParseFrame> = emptyList()
) {

override fun toString(): String

    override fun toString(): String {
        val line = lineHint?.let { ", lineHint=$it" } ?: ""
        val stack = if (stackTrace.isEmpty()) "" else ", stack=${stackTrace.size}"
        return "PatParseContext(source=${source.prettyQuoted(40)}$line$stack)"
    }
}

data class PatSourceSpan

data class PatSourceSpan(
    val context: PatParseContext,
    val start: Int,
    val endExclusive: Int
) {

init

    init {
        require(start in 0..endExclusive) { "start must be in 0..endExclusive" }
        require(endExclusive <= context.source.length) { "endExclusive must be within source length" }
    }

val source: String

    val source: String
        get() = context.source

val lineHint: Int?

    val lineHint: Int?
        get() = context.lineHint

val stackTrace: List<PatParseFrame>

    val stackTrace: List<PatParseFrame>
        get() = context.stackTrace

val text: String

    val text: String
        get() = source.substring(start, endExclusive)

val length: Int

    val length: Int
        get() = endExclusive - start

override fun toString(): String

    override fun toString(): String {
        val line = lineHint?.let { ", line=$it" } ?: ""
        return "PatSourceSpan(${start}..$endExclusive${line}, text=${text.prettyQuoted(24)})"
    }
}

data class PatEvent<A>

One event: a value sounding over arc.

arc is the part — the slice of the event that the query being answered can actually see. A query never returns anything outside its own window, so an event longer than the window, or one straddling the window's edge, comes back clipped. whole is the logical extent it was clipped from: where the note really starts and ends, regardless of who is looking.

Keeping both is what makes hasOnset answerable, and hasOnset is what stops a note being played twice. pat("a b c").slow(2) puts b across the bar line at 2/3 → 4/3; a per-cycle scheduler asks for [0,1) and then [1,2), and gets b back both times. Without whole those two fragments are indistinguishable from two separate notes, and the note is struck twice — once at 2/3 and again at the bar line. With it, only the fragment whose part starts where the whole starts is an onset, and b is struck once, for its full length. This is Tidal's whole/part distinction and it exists for exactly this reason.

whole == null means an analog event: a sample of a continuous signal, which has no intrinsic start because it was never a note in the first place. Analog events count as their own onset, so a signal read per cycle behaves the way you would expect; discretise with segment to turn one into events with real onsets.

Move an event with mapTime and re-value it with withValue; both carry whole across. Building a new PatEvent positionally from an old one silently drops it back to the part, which is the bug described above.

data class PatEvent<A>(
    val arc: PatArc,
    val value: A,
    val sourceSpan: PatSourceSpan? = null,
    val annotations: Map<String, String> = emptyMap(),
    val additionalSpans: List<PatSourceSpan> = emptyList(),
    val whole: PatArc? = arc
) {

val hasOnset: Boolean

True when this fragment carries the start of its event, so a scheduler should strike it.

Analog events (whole == null) are always onsets: there is no earlier fragment they could be the tail of.

    val hasOnset: Boolean
        get() = whole == null || whole.start == arc.start

val extent: PatArc

The event's real length — whole where there is one, otherwise the part that was visible.

    val extent: PatArc
        get() = whole ?: arc

fun shift(by: PatQ) = copy(arc = arc.shift(by), whole = whole?.shift(by))

fun scale(by: PatQ) = copy(arc = arc.scale(by), whole = whole?.scale(by))

inline fun mapTime(f: (PatQ) -> PatQ) = copy(arc = arc.map(f), whole = whole?.map(f))

Moves the event in time, applying f to the part and the whole alike.

fun <B> withValue(newValue: B): PatEvent<B>

Same event, same time, different value — keeps whole, which a positional rebuild drops.

    fun <B> withValue(newValue: B): PatEvent<B> =
        PatEvent(arc, newValue, sourceSpan, annotations, additionalSpans, whole)

fun clipTo(part: PatArc) = copy(arc = part, whole = whole)

Restricts the visible part to part without touching where the event logically begins.

fun mergeAnnotations(extra: Map<String, String>)

    fun mergeAnnotations(extra: Map<String, String>) = if (extra.isEmpty()) this else copy(annotations = annotations + extra)

fun addSpan(span: PatSourceSpan?)

    fun addSpan(span: PatSourceSpan?) = if (span == null) this else copy(additionalSpans = additionalSpans + span)

override fun toString(): String

    override fun toString(): String {
        val source = sourceSpan?.let { ", source=$it" } ?: ""
        val ann = if (annotations.isEmpty()) "" else ", annotations=$annotations"
        val extra = if (additionalSpans.isEmpty()) "" else ", +${additionalSpans.size} spans"
        // Only worth showing when it differs from the part — otherwise every event grows a duplicate.
        val extent = when {
            whole == null -> ", analog"
            whole != arc -> ", whole=$whole"
            else -> ""
        }
        return "PatEvent(arc=$arc$extent, value=${synthPrettyValue(value)}$source$ann$extra)"
    }
}

data class PatScheduledEvent<A>

data class PatScheduledEvent<A>(
    val start: Double,
    val end: Double,
    val value: A,
    val cycleIndex: Long,
    val arc: PatArc,
    val sourceSpan: PatSourceSpan? = null,
    val annotations: Map<String, String> = emptyMap(),
    val additionalSpans: List<PatSourceSpan> = emptyList()
) {

init

    init {
        require(start.isFinite()) { "start must be finite" }
        require(end.isFinite()) { "end must be finite" }
        require(end >= start) { "end must be >= start" }
    }

val duration: Double

    val duration: Double
        get() = end - start

val sourceText: String?

    val sourceText: String?
        get() = sourceSpan?.text

operator fun get(key: String): String? = annotations[key]

val allSpans: List<PatSourceSpan>

    val allSpans: List<PatSourceSpan>
        get() = listOfNotNull(sourceSpan) + additionalSpans

override fun toString(): String

    override fun toString(): String {
        val source = sourceText?.let { ", source=${it.prettyQuoted(24)}" } ?: ""
        val ann = if (annotations.isEmpty()) "" else ", annotations=$annotations"
        val extra = if (additionalSpans.isEmpty()) "" else ", +${additionalSpans.size} spans"
        return "PatScheduledEvent(start=${start.prettyNumber()}, end=${end.prettyNumber()}, value=${synthPrettyValue(value)}, cycle=$cycleIndex, arc=$arc$source$ann$extra)"
    }
}

class PatPattern<A>(private val renderAbs: (PatArc) -> List<PatEvent<A>>) {

Musical structure as a function of time: ask a PatPattern<A> for a PatArc and it answers with the events inside it.

It is not a list, and that is the whole design. There is nothing stored to run off the end of, so a pattern that never repeats costs no more to hold than one that repeats every bar; fast, rev, every and the rest are cheap because they compose the query rather than rebuild any events; and asking about cycle 10,000 costs the same as asking about cycle 0. Time stays exact (PatQ) through every transform, so a third of a cycle is still a third after a round trip.

A is unconstrained. An atom is whatever you want it to mean — a drum name for playDirt, a note number for Sequencing, a PatFunctionAtom for the Fn algebra, a parameter value, or a lambda's argument for an arbitrary side effect. Nothing in this type knows about sound.

Two ways out. events is the raw query, in cycles, for anything building a transform. eventSequence is what a scheduler wants: playing order, one cycle at a time, each event yielded exactly once at its own onset and widened back to its real length — see its own KDoc for why the naive version strikes some notes twice.

class PatPattern<A>(private val renderAbs: (PatArc) -> List<PatEvent<A>>) {

fun events(absArc: PatArc): List<PatEvent<A>> = renderAbs(absArc)

fun eventSequence(fromCycle: Long = 0): Sequence<PatEvent<A>>

The events in playing order, one cycle at a time, each yielded once at its own start.

Querying cycle by cycle is what a scheduler has to do — it cannot ask for all of time — but it means any event crossing a bar line comes back from two consecutive queries, clipped both times. Yielding both fragments strikes the note twice: pat("a b c").slow(2) would fire b at 2/3 and again at the bar line, because three steps do not divide into two cycles. So only the fragment carrying the onset is yielded, and it is widened back to its PatEvent.whole so the consumer gets the note's real length rather than the part that fitted inside one cycle.

A note already sounding when fromCycle begins is therefore not replayed: its onset is in an earlier cycle, which is the same answer a musician would give.

    fun eventSequence(fromCycle: Long = 0): Sequence<PatEvent<A>> = sequence {
        var cycle = fromCycle
        while (true) {
            val arc = PatArc(PatQ.of(cycle), PatQ.of(cycle + 1))
            val events = events(arc).sortedBy { it.arc.start.toDouble() }
            for (event in events) {
                if (!event.hasOnset) continue
                yield(if (event.whole != null) event.copy(arc = event.whole) else event)
            }
            cycle++
        }
    }

fun scheduled

    fun scheduled(
        cps: Number = 1.0,
        fromCycle: Long? = null,
        startAt: Number = 0.0,
        clock: PatClock? = defaultPatClock()
    ): Sequence<PatScheduledEvent<A>> = sequence {
        val cpsValue = cps.toDouble().coerceAtLeast(1e-6)
        val secPerCycle = 1.0 / cpsValue
        val offset = startAt.toDouble()
        val queryStart = clock?.now ?: offset
        val firstCycle = when {
            fromCycle != null -> fromCycle
            clock == null -> 0L
            else -> floor((queryStart - offset) * cpsValue).toLong()
        }
        for (event in eventSequence(firstCycle)) {
            val scheduled = PatScheduledEvent(
                start = offset + event.arc.start.toDouble() * secPerCycle,
                end = offset + event.arc.end.toDouble() * secPerCycle,
                value = event.value,
                cycleIndex = qFloor(event.arc.start),
                arc = event.arc,
                sourceSpan = event.sourceSpan,
                annotations = event.annotations,
                additionalSpans = event.additionalSpans
            )
            if (clock == null || scheduled.start + 1e-9 >= queryStart) {
                yield(scheduled)
            }
        }
    }

fun <B> map(transform: (A) -> B): PatPattern<B> = PatPattern { absArc ->

    fun <B> map(transform: (A) -> B): PatPattern<B> = PatPattern { absArc ->
        events(absArc).map { it.withValue(transform(it.value)) }
    }

fun respond

    fun respond(
        cps: Number = 1.0,
        fromCycle: Long? = null,
        dispatchOnMain: Boolean = true,
        clock: PatClock? = defaultPatClock(),
        ahead: Number = 0.0,
        callback: PatResponseScope<A>.(PatScheduledEvent<A>) -> Unit
    ): PatResponseSpec<A> = PatResponseSpec(
        pattern = this,
        cps = cps.toDouble().coerceAtLeast(1e-6),
        fromCycle = fromCycle,
        dispatchOnMain = dispatchOnMain,
        clock = clock,
        ahead = ahead.toDouble().coerceAtLeast(0.0),
        callback = callback
    )

fun respondAhead

    fun respondAhead(
        ahead: Number,
        cps: Number = 1.0,
        fromCycle: Long? = null,
        dispatchOnMain: Boolean = true,
        clock: PatClock? = defaultPatClock(),
        callback: PatResponseScope<A>.(PatScheduledEvent<A>) -> Unit
    ): PatResponseSpec<A> = respond(
        cps = cps,
        fromCycle = fromCycle,
        dispatchOnMain = dispatchOnMain,
        clock = clock,
        ahead = ahead,
        callback = callback
    )

override fun toString(): String = "PatPattern(...)"

private val PAT_CYCLE = PatArc(PatQ.ZERO, PatQ.ONE)

fun <A> fromCycle(renderCycle: (PatArc) -> List<PatEvent<A>>): PatPattern<A> =

fun <A> fromCycle(renderCycle: (PatArc) -> List<PatEvent<A>>): PatPattern<A> =
    fromCycleIndexed { subArc, _ -> renderCycle(subArc) }

fun <A> fromCycleIndexed(renderCycle: (PatArc, Long) -> List<PatEvent<A>>): PatPattern<A> =

fun <A> fromCycleIndexed(renderCycle: (PatArc, Long) -> List<PatEvent<A>>): PatPattern<A> =
    PatPattern { absArc ->
        val startTile = qFloor(absArc.start)
        val endTile = qCeil(absArc.end)
        val out = mutableListOf<PatEvent<A>>()
        for (tile in startTile until endTile) {
            val base = PatQ.of(tile)
            val tileAbs = PatArc(base, base + PatQ.ONE)
            val interAbs = absArc.intersect(tileAbs) ?: continue
            val localSpan = interAbs.shift(-base)
            out += renderCycle(localSpan, tile).map { it.shift(base) }
        }
        out
    }

fun <A> pure(value: A, sourceSpan: PatSourceSpan? = null): PatPattern<A>

fun <A> pure(value: A, sourceSpan: PatSourceSpan? = null): PatPattern<A> = fromCycle { subArc ->
    val inter = PAT_CYCLE.intersect(subArc) ?: return@fromCycle emptyList()
    // The event is the whole cycle; `inter` is only as much of it as this query asked to see. Every
    // clipped event in the language is born here, so this is where `whole` has to be set — see
    // [PatEvent].
    listOf(PatEvent(inter, value, sourceSpan, whole = PAT_CYCLE))
}

fun <A> silence(): PatPattern<A> = PatPattern { emptyList() }

fun <A> within(window: PatArc, pattern: PatPattern<A>): PatPattern<A>

fun <A> within(window: PatArc, pattern: PatPattern<A>): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absSub = subArc.shift(base)
        val windowAbs = PatArc(base + window.start, base + window.end)
        val interAbs = absSub.intersect(windowAbs) ?: return@fromCycleIndexed emptyList()
        val toLocal = PatQ.ONE / window.dur
        val localInWindow = PatArc(
            (interAbs.start - windowAbs.start) * toLocal,
            (interAbs.end - windowAbs.start) * toLocal
        )
        val childAbs = localInWindow.shift(base)
        pattern.events(childAbs).map { event ->
            event.mapTime { t -> windowAbs.start + (t - base) * window.dur - base }
        }
    }

internal fun <A> patFocus(window: PatArc, cycleIndex: Long, pattern: PatPattern<A>): PatPattern<A>

Maps cycle cycleIndex of pattern onto the absolute arc window.

The absolute-time counterpart of within, which reads its window as a local arc inside a single cycle (base + window.start). That distinction was a real bug: MiniPat.compilePolyLayer builds absolute windows — repetition r of a three-slot layer against an eight-pulse grid sits at [3r/8, (3r+1)/8), which leaves [0, 1) as soon as r >= 3 — and handed them to within, whose tile arithmetic then found no intersection. The result was that every polymeter went silent after cycle 0: pat("{bd hh 808:4}%8") played eight events in the first bar and nothing ever again. MiniNotationTest pins the fix.

cycleIndex is the child's own cycle, so per-cycle constructs inside a polymeter layer — <a b>, every, ? — advance once per repetition rather than being frozen on cycle 0.

internal fun <A> patFocus(window: PatArc, cycleIndex: Long, pattern: PatPattern<A>): PatPattern<A> =
    PatPattern { arc ->
        val visible = arc.intersect(window) ?: return@PatPattern emptyList()
        val span = window.dur
        if (span <= PatQ.ZERO) return@PatPattern emptyList()
        val base = PatQ.of(cycleIndex)
        val localStart = base + (visible.start - window.start) / span
        val localEnd = base + (visible.end - window.start) / span
        if (localEnd <= localStart) return@PatPattern emptyList()
        pattern.events(PatArc(localStart, localEnd)).map { event ->
            event.mapTime { t -> window.start + (t - base) * span }
        }
    }

fun <A> cat(vararg patterns: PatPattern<A>): PatPattern<A>

Fits every pattern into one cycle, side by side.

Note for readers coming from Tidal: this is Tidal's fastcat, not Tidal's cat. Tidal's cat plays one pattern per cycle, which here is slowcat. fastcat is available as a spelling of this one that says so out loud.

fun <A> cat(vararg patterns: PatPattern<A>): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absSub = subArc.shift(base)
        val out = mutableListOf<PatEvent<A>>()
        val count = max(1, patterns.size)
        for (index in 0 until count) {
            val segment = PatArc(PatQ.of(index, count), PatQ.of(index + 1, count))
            val windowAbs = PatArc(base + segment.start, base + segment.end)
            val interAbs = absSub.intersect(windowAbs) ?: continue
            out += within(segment, patterns[index]).events(interAbs).map { it.shift(-base) }
        }
        out
    }

fun <A> fastcat(vararg patterns: PatPattern<A>): PatPattern<A> = cat(*patterns)

cat under the name Tidal gives this behaviour, for when the distinction matters to the reader.

fun <A> slowcat(vararg patterns: PatPattern<A>): PatPattern<A>

Plays one pattern per cycle, in turn — Tidal's cat (also its slowcat).

slowcat(pat("a b"), pat("c")) reads a b in one cycle and c in the next, where cat would fit both into every cycle. It is what mini-notation's <a b> alternation has always been built from; that construction was inlined in the compiler until chunk needed it too.

fun <A> slowcat(vararg patterns: PatPattern<A>): PatPattern<A> {
    if (patterns.isEmpty()) return silence()
    return fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val size = patterns.size.toLong()
        val index = (((tile % size) + size) % size).toInt()
        patterns[index].events(subArc.shift(base)).map { it.shift(-base) }
    }
}

fun <A> stack(vararg patterns: PatPattern<A>): PatPattern<A>

fun <A> stack(vararg patterns: PatPattern<A>): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absSub = subArc.shift(base)
        patterns.flatMap { pattern -> pattern.events(absSub).map { it.shift(-base) } }
    }

fun <A> PatPattern<A>.rev(): PatPattern<A>

fun <A> PatPattern<A>.rev(): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val reflectedAbs = PatArc(base + PatQ.ONE - subArc.end, base + PatQ.ONE - subArc.start)
        events(reflectedAbs).map { event ->
            // Decreasing map — PatArc.map puts the reflected ends back in order.
            event.mapTime { t -> PatQ.ONE - (t - base) }
        }
    }

fun <A> PatPattern<A>.palindrome(): PatPattern<A> = cat(this, rev())

fun <A> PatPattern<A>.zoom(start: PatQ, len: PatQ): PatPattern<A>

fun <A> PatPattern<A>.zoom(start: PatQ, len: PatQ): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absArc = PatArc(base + start + subArc.start * len, base + start + subArc.end * len)
        events(absArc).map { event -> event.mapTime { t -> (t - (base + start)) / len } }
    }

fun <A> PatPattern<A>.delay(by: PatQ): PatPattern<A>

fun <A> PatPattern<A>.delay(by: PatQ): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absArc = subArc.shift(base - by)
        events(absArc).map { event -> event.shift(-base + by) }
    }

fun <A> PatPattern<A>.stretch(by: PatQ): PatPattern<A>

Multiplies the pattern's timeline by by — the one time transform, in absolute cycle time.

stretch(PatQ.of(2)) is twice as slow and genuinely spans two cycles; stretch(PatQ.of(1, 2)) is twice as fast. Every other time operator in this file is defined in terms of it.

This replaced three per-cycle-anchored implementations that all silently dropped material. pat("a b c d").slow(2) used to give a b in every cycle — c d was never heard, because the old implementation rescaled inside each cycle rather than across cycles. Same for mini-notation's /n, for timeScale, density, and therefore for chunk, whose whole point is to apply a transform to a different quarter each cycle and which used to apply it to the same one forever. PatContractTest pins the difference: slow is now (correctly) a per-cycle-varying pattern.

fun <A> PatPattern<A>.stretch(by: PatQ): PatPattern<A> {
    if (by <= PatQ.ZERO) return this
    return PatPattern { arc ->
        // The whole rides along with the part: an event stretched across a bar line keeps knowing
        // where it really began, which is what stops `eventSequence` striking it twice.
        events(PatArc(arc.start / by, arc.end / by)).map { event -> event.scale(by) }
    }
}

fun <A> PatPattern<A>.fastBy(factor: PatQ): PatPattern<A>

stretch by an exact fraction — fastBy(PatQ.of(3, 2)) is one and a half times as fast.

fun <A> PatPattern<A>.fastBy(factor: PatQ): PatPattern<A> =
    if (factor <= PatQ.ZERO) this else stretch(PatQ.ONE / factor)

fun <A> PatPattern<A>.slowBy(factor: PatQ): PatPattern<A> = stretch(factor)

stretch by an exact fraction — slowBy(PatQ.of(3, 2)) takes one and a half cycles per cycle.

fun <A> PatPattern<A>.fast(n: Int): PatPattern<A> = fastBy(PatQ.of(max(1, n)))

Plays the pattern n times per cycle.

Cycle-spanning, so pat("<a b>").fast(2) reads a b in one cycle rather than a a. For a cycle-invariant pattern it is identical to the old cat-of-copies implementation.

fun <A> PatPattern<A>.slow(n: Int): PatPattern<A> = stretch(PatQ.of(max(1, n)))

Takes n cycles to play the pattern once.

pat("a b c d").slow(2) reads a b in one cycle and c d in the next — the second half really is heard. See stretch for what this used to do instead.

fun <A> PatPattern<A>.every(k: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =

fun <A> PatPattern<A>.every(k: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val use = if (k <= 0) this else if ((tile % k) == 0L) transform(this) else this
        val base = PatQ.of(tile)
        use.events(subArc.shift(base)).map { it.shift(-base) }
    }

fun <A> PatPattern<A>.switchEvery(k: Int, alt: PatPattern<A>): PatPattern<A> = every(k) { alt }

fun <A> PatPattern<A>.whenCycle

fun <A> PatPattern<A>.whenCycle(
    predicate: (Long) -> Boolean,
    transform: (PatPattern<A>) -> PatPattern<A>
): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val use = if (predicate(tile)) transform(this) else this
        val base = PatQ.of(tile)
        use.events(subArc.shift(base)).map { it.shift(-base) }
    }

fun <A> join(patterns: PatPattern<PatPattern<A>>): PatPattern<A>

fun <A> join(patterns: PatPattern<PatPattern<A>>): PatPattern<A> =
    PatPattern { absArc ->
        patterns.events(absArc).flatMap { controller -> controller.value.events(controller.arc) }
    }

fun <A> patOf(vararg values: A): PatPattern<A> = cat(*values.map { pure(it) }.toTypedArray())

fun <A> PatPattern<A>.subdivide(n: Int): PatPattern<A>

fun <A> PatPattern<A>.subdivide(n: Int): PatPattern<A> =
    cat(*Array(max(1, n)) { zoom(PatQ.ZERO, PatQ.ONE / PatQ.of(max(1, n))) })

fun <A> PatPattern<A>.sometimesBy

fun <A> PatPattern<A>.sometimesBy(
    probability: Double,
    salt: Int = 0,
    transform: (PatPattern<A>) -> PatPattern<A>
): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val unit = toUnitDouble(splitmix64(tile xor (salt.toLong() shl 1)))
        val use = if (unit < probability.coerceIn(0.0, 1.0)) transform(this) else this
        val base = PatQ.of(tile)
        use.events(subArc.shift(base)).map { it.shift(-base) }
    }

fun <A> PatPattern<A>.sometimes(transform: (PatPattern<A>) -> PatPattern<A>) =

fun <A> PatPattern<A>.sometimes(transform: (PatPattern<A>) -> PatPattern<A>) =
    sometimesBy(0.5, 0, transform)

fun <A> PatPattern<A>.degradeBy(probability: Double, salt: Int = 0): PatPattern<A>

fun <A> PatPattern<A>.degradeBy(probability: Double, salt: Int = 0): PatPattern<A> =
    mapEventsPerCycle { cycle, events ->
        val p = probability.coerceIn(0.0, 1.0)
        events.filter { event ->
            val unit = toUnitDouble(splitmix64(eventKey(cycle, event.arc, salt)))
            unit >= p
        }
    }

fun <A> PatPattern<A>.degrade(): PatPattern<A> = degradeBy(0.5)

fun bjorklund(pulses: Int, steps: Int): List<Boolean>

pulses onsets spread as evenly as steps slots allow — Bjorklund's algorithm, which is Euclid's applied to a rhythm. bjorklund(3, 8) is x..x..x., the tresillo.

The spelling is Tidal's, and deliberately so: bd(3,8) has to name the same figure in both languages or the shared notation is a trap. Tidal takes its implementation from hmt (Rohan Drape's Sound.Tidal.Bjorklund), which pairs the onset list against the rest list until fewer than two rests remain; this is the same recurrence in the form Toussaint writes it. The two were compared over every (pulses, steps) pair up to 256 steps — 33,152 of them — and agree everywhere. PatKolakoskiTest.bjorklund spells Tidal's figure, not a rotation of it keeps a table of eighteen of Tidal's own outputs, so a drift here fails against Tidal rather than against a remembered Field.

Until 2026-09-13 the loop stopped when the onset count reached one rather than the rest count, running one pairing too far. That yielded a rotation of this figure — the same necklace, so still maximally even, but starting on a different bead for 292 of the 496 non-trivial pairs to 32 steps. bd(3,8) came out x..x.x... See dev_docs/PAT_GALLERY.md.

fun bjorklund(pulses: Int, steps: Int): List<Boolean> {
    require(pulses in 0..steps)
    if (pulses == 0) return List(steps) { false }
    if (pulses == steps) return List(steps) { true }

    var onsets: List<List<Boolean>> = List(pulses) { listOf(true) }
    var rests: List<List<Boolean>> = List(steps - pulses) { listOf(false) }
    while (rests.size > 1) {
        val paired = minOf(onsets.size, rests.size)
        val next = List(paired) { onsets[it] + rests[it] }
        rests = if (onsets.size > paired) onsets.drop(paired) else rests.drop(paired)
        onsets = next
    }
    return (onsets + rests).flatten()
}

fun euclid(pulses: Int, steps: Int, rotation: Int = 0): PatPattern<Boolean>

fun euclid(pulses: Int, steps: Int, rotation: Int = 0): PatPattern<Boolean> =
    fromCycle { subArc ->
        val stepCount = steps.coerceAtLeast(1)
        val mask = bjorklund(pulses, stepCount).rotateRight(rotation)
        buildList {
            for (index in 0 until stepCount) {
                if (index < mask.size && mask[index]) {
                    val window = PatArc(PatQ.of(index, stepCount), PatQ.of(index + 1, stepCount))
                    val inter = window.intersect(subArc) ?: continue
                    // The pulse lasts `window`; `inter` is only as much of it as this query can see.
                    add(PatEvent(inter, true, whole = window))
                }
            }
        }
    }

fun <A> mask(mask: PatPattern<Boolean>, on: PatPattern<A>): PatPattern<A>

fun <A> mask(mask: PatPattern<Boolean>, on: PatPattern<A>): PatPattern<A> =
    fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absSub = subArc.shift(base)
        val maskEvents = mask.events(absSub)
        val out = mutableListOf<PatEvent<A>>()
        for (maskEvent in maskEvents) {
            if (!maskEvent.value) continue
            out += within(maskEvent.arc.shift(-base), on).events(absSub).map { it.shift(-base) }
        }
        out
    }

fun <A> euclidFill(pulses: Int, steps: Int, rotation: Int = 0, on: PatPattern<A>): PatPattern<A>

fun <A> euclidFill(pulses: Int, steps: Int, rotation: Int = 0, on: PatPattern<A>): PatPattern<A> =
    mask(euclid(pulses, steps, rotation), on)

fun <A> PatPattern<A>.striate(n: Int, rotation: Int = 0): PatPattern<A>

fun <A> PatPattern<A>.striate(n: Int, rotation: Int = 0): PatPattern<A> =
    mapEventsPerCycle { _, events ->
        val slices = n.coerceAtLeast(1)
        val rot = ((rotation % slices) + slices) % slices
        buildList {
            for (event in events) {
                val step = event.arc.dur / PatQ.of(slices)
                for (index in 0 until slices) {
                    val rotated = (index + rot) % slices
                    val start = event.arc.start + step * PatQ.of(rotated)
                    add(PatEvent(PatArc(start, start + step), event.value, event.sourceSpan, event.annotations, event.additionalSpans))
                }
            }
        }
    }

fun <A> PatPattern<A>.stutter(n: Int, gap: PatQ = PatQ.ZERO): PatPattern<A>

fun <A> PatPattern<A>.stutter(n: Int, gap: PatQ = PatQ.ZERO): PatPattern<A> =
    mapEventsPerCycle { _, events ->
        val repeats = n.coerceAtLeast(1)
        buildList {
            for (event in events) {
                val slot = event.arc.dur / PatQ.of(repeats)
                val play = slot * (PatQ.ONE - gap)
                for (index in 0 until repeats) {
                    val start = event.arc.start + slot * PatQ.of(index)
                    val end = start + play
                    if (end > start) add(PatEvent(PatArc(start, end), event.value, event.sourceSpan, event.annotations, event.additionalSpans))
                }
            }
        }
    }

fun <A> PatPattern<A>.whenMod

fun <A> PatPattern<A>.whenMod(
    k: Int,
    n: Int,
    transform: (PatPattern<A>) -> PatPattern<A>
): PatPattern<A> = whenCycle({ tile -> (tile % n) == (k % n).toLong() }, transform)

fun <A> PatPattern<A>.playWhen(predicate: (cyclePos: PatQ) -> Boolean): PatPattern<A> =

Keeps only the events whose onset satisfies predicate, which is asked about cycle position.

Filtering on the onset rather than on the visible part is the point: an event straddling the edge of a region either belongs to it or does not, and clipping it in half would invent a note. Tidal's playWhen — and only expressible here once PatEvent.whole could say where an event begins.

fun <A> PatPattern<A>.playWhen(predicate: (cyclePos: PatQ) -> Boolean): PatPattern<A> =
    PatPattern { arc ->
        events(arc).filter { event ->
            val start = event.extent.start
            predicate(start - PatQ.of(qFloor(start)))
        }
    }

fun <A> PatPattern<A>.withinCycle(window: PatArc, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> {

Applies transform only to the events starting inside window of each cycle; the rest play on untouched.

This is Tidal's within. Field's existing within is a different operator that happens to share the name: it squeezes a whole pattern into a window, which is Tidal's compress.

fun <A> PatPattern<A>.withinCycle(window: PatArc, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> {
    val inside = { pos: PatQ -> pos >= window.start && pos < window.end }
    return stack(transform(this).playWhen(inside), playWhen { !inside(it) })
}

fun <A> PatPattern<A>.chunk(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> {

Applies transform to a different 1/n of the cycle each cycle, walking through all n of them before it repeats.

pat("a b c d").chunk(4) { it.rev() } reverses the first quarter in cycle 0, the second in cycle 1, and so on: the transform travels through the bar. That travelling is the whole point of the operator, and this is the first implementation here that does it. chunk used to be slow(n).let(transform).fast(n) — a real and useful Tidal operator, but a different one. That is inside: it is cycle-invariant and applies the transform to the same material every cycle. The two were conflated, and the commit and documentation that introduced the conflation claimed chunk had been fixed to walk when it never had. inside now carries the old behaviour under its own name.

fun <A> PatPattern<A>.chunk(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> {
    val parts = max(1, n)
    // slowcat, not cat: the point is a different quarter *each cycle*. Field's `cat` fits all of
    // its arguments into one cycle, which is what made the first draft of this walk nowhere.
    return slowcat(*Array(parts) { index ->
        withinCycle(PatArc(PatQ.of(index, parts), PatQ.of(index + 1, parts)), transform)
    })
}

fun <A> PatPattern<A>.inside(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =

Slows by n, applies transform, speeds back up — so the transform sees n cycles' worth of material as a single cycle. Tidal's inside.

Cycle-invariant, which is exactly how it differs from chunk.

fun <A> PatPattern<A>.inside(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =
    slow(max(1, n)).let(transform).fast(max(1, n))

fun <A> PatPattern<A>.outside(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =

The mirror of inside: speeds up, transforms, slows back down. Tidal's outside.

fun <A> PatPattern<A>.outside(n: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> =
    fast(max(1, n)).let(transform).slow(max(1, n))

fun <A> PatPattern<A>.timeScale(ratio: PatQ): PatPattern<A> = fastBy(ratio)

Scales density by an exact ratio: timeScale(PatQ.of(3, 2)) is half again as fast. stretch's inverse.

fun <A> PatPattern<A>.density(ratio: Double): PatPattern<A>

fun <A> PatPattern<A>.density(ratio: Double): PatPattern<A> =
    timeScale(if (ratio <= 0.0) PatQ.ONE else PatQ.fromDouble(ratio))

enum class ArpMode

enum class ArpMode {
    UP, DOWN, UPDOWN, DOWNUP, RANDOM, PINGPONG
}

fun <A> PatPattern<A>.arp

Turns each event into an arpeggio over notes.

Each input event is subdivided into at least one, and otherwise speed, steps and mapped through mode; an empty note list produces silence. After the first traversal, hold keeps using notes.last() instead of wrapping, and seed makes random mode repeatable.

body — 58 lines, from line 1030
fun <A> PatPattern<A>.arp(
    notes: List<A>,
    mode: ArpMode = ArpMode.UP,
    speed: Int = 1,
    hold: Boolean = false,
    seed: Int = 0
): PatPattern<A> {
    if (notes.isEmpty()) return silence()

    val noteCount = notes.size
    val arpLength = max(1, speed)
    val modeSequence = when (mode) {
        ArpMode.UP -> (0 until noteCount).toList()
        ArpMode.DOWN -> (noteCount - 1 downTo 0).toList()
        ArpMode.UPDOWN -> (0 until noteCount).toList() + (noteCount - 2 downTo 1).toList()
        ArpMode.DOWNUP -> (noteCount - 1 downTo 0).toList() + (1 until noteCount - 1).toList()
        ArpMode.PINGPONG -> (0 until noteCount).toList() + (noteCount - 2 downTo 1).toList()
        ArpMode.RANDOM -> {
            val rand = kotlin.random.Random(seed)
            (0 until noteCount).map { rand.nextInt(noteCount) }
        }
    }

    return fromCycleIndexed { subArc, tile ->
        val base = PatQ.of(tile)
        val absArc = subArc.shift(base)
        val events = events(absArc)
        val out = mutableListOf<PatEvent<A>>()

        var eventCounter = 0
        for (event in events) {
            val eventDur = event.arc.dur
            val stepDur = eventDur / PatQ.of(arpLength)

            for (step in 0 until arpLength) {
                val globalStep = eventCounter * arpLength + step
                val noteIdx = (globalStep % modeSequence.size).let { 
                    if (globalStep >= modeSequence.size && hold) noteCount - 1 
                    else modeSequence[it]
                }

                val stepStart = event.arc.start + stepDur * PatQ.of(step)
                val stepEnd = if (step == arpLength - 1) event.arc.end 
                              else event.arc.start + stepDur * PatQ.of(step + 1)

                out += PatEvent(
                    PatArc(stepStart, stepEnd).shift(-base),
                    notes[noteIdx],
                    event.sourceSpan,
                    event.annotations,
                    event.additionalSpans
                )
            }
            eventCounter++
        }
        out
    }
}

fun <A> PatPattern<A>.arp

fun <A> PatPattern<A>.arp(
    vararg notes: A,
    mode: ArpMode = ArpMode.UP,
    speed: Int = 1,
    hold: Boolean = false,
    seed: Int = 0
): PatPattern<A> = arp(notes.toList(), mode, speed, hold, seed)

fun PatPattern<String>.arp(indexPattern: String): PatPattern<String> = arp(pat(indexPattern))

fun PatPattern<String>.arp(indexPattern: PatPattern<String>): PatPattern<String>

fun PatPattern<String>.arp(indexPattern: PatPattern<String>): PatPattern<String> = fromCycleIndexed { subArc, tile ->
    val base = PatQ.of(tile)
    val absArc = subArc.shift(base)
    val sourceEvents = events(absArc)
    if (sourceEvents.isEmpty()) return@fromCycleIndexed emptyList()

    val groupedByArc = sourceEvents
        .groupBy { it.arc }
        .entries
        .sortedWith(compareBy({ it.key.start }, { it.key.end }))

    val out = mutableListOf<PatEvent<String>>()

    for ((arc, chordEvents) in groupedByArc) {
        if (chordEvents.isEmpty()) continue
        val indexEvents = indexPattern.events(arc)

        for (indexEvent in indexEvents) {
            val inter = indexEvent.arc.intersect(arc) ?: continue
            val parsedIndex = parseArpIndexToken(indexEvent.value) ?: continue
            val selected = chordEvents[floorMod(parsedIndex.toLong(), chordEvents.size)]
            out += PatEvent(inter.shift(-base), selected.value, selected.sourceSpan, selected.annotations, selected.additionalSpans + listOfNotNull(indexEvent.sourceSpan))
        }
    }

    out
}

fun PatPattern<String>.zip(valuePattern: String, key: String): PatPattern<String>

fun PatPattern<String>.zip(valuePattern: String, key: String): PatPattern<String> = zip(pat(valuePattern), key)

fun PatPattern<String>.zip(valuePattern: PatPattern<String>, key: String): PatPattern<String>

fun PatPattern<String>.zip(valuePattern: PatPattern<String>, key: String): PatPattern<String> = fromCycleIndexed { subArc, tile ->
    val base = PatQ.of(tile)
    val absArc = subArc.shift(base)
    val sourceEvents = events(absArc)
    if (sourceEvents.isEmpty()) return@fromCycleIndexed emptyList()

    val zipEvents = valuePattern.events(absArc)
    sourceEvents.map { event ->
        val overlapping = zipEvents.firstOrNull { it.arc.intersect(event.arc) != null }
        // `shift`, not `copy(arc = …)`: the whole has to return to local time along with the part,
        // or the event ends up claiming to begin a cycle away from where it actually sounds.
        if (overlapping != null) {
            event.mergeAnnotations(mapOf(key to overlapping.value)).addSpan(overlapping.sourceSpan).shift(-base)
        } else {
            event.shift(-base)
        }
    }
}

object MiniPat

sealed interface Node

data class Atom(val name: String, val sourceSpan: PatSourceSpan) : Node

data object Rest : Node

data class Seq(val items: List<Node>) : Node

data class Stk(val layers: List<Node>) : Node

data class Choice(val alts: List<Node>) : Node

data class RandomChoice(val alts: List<Node>, val salt: Int) : Node

data class Poly(val parts: List<Node>, val subdivision: Int? = null) : Node

data class Repeat(val expr: Node, val times: Int) : Node

data class Fast(val expr: Node, val n: Int) : Node

data class Slow(val expr: Node, val n: Int) : Node

data class Elongate(val expr: Node, val steps: Int) : Node

data class Euclid(val expr: Node, val pulses: Int, val steps: Int, val offset: Int = 0) : Node

data class Kolakoski

a(k16) / a(g16,3) — a Kolakoski figure over steps steps. See PatKolakoski.kt.

    data class Kolakoski(
        val expr: Node,
        val steps: Int,
        val reading: KolakoskiReading,
        val offset: Int = 0
    ) : Node

data class Degrade(val expr: Node, val probability: Double, val salt: Int) : Node

a? / a?0.3 — drop this term with the given probability, independently per event.

data class Expr(val body: String, val sourceSpan: PatSourceSpan) : Node

fun parseToAst

    fun parseToAst(
        source: String,
        parseContext: PatParseContext = PatParseContext(source)
    ): Node = Parser(source, parseContext).parseTop()

fun compile(ast: Node): PatPattern<String> = compileNode(ast)

fun parse

    fun parse(
        source: String,
        parseContext: PatParseContext = PatParseContext(source)
    ): PatPattern<String> = compile(parseToAst(source, parseContext))

Characters that may follow a standalone . / _ / ! step marker.

source — 380 lines, from line 1186
    private val STANDALONE_FOLLOWERS = setOf(']', '}', '>', ')', ',', '|', '.', '_', '!')

    private class Parser(private val src: String, private val parseContext: PatParseContext) {
        private var index = 0
        private var nextSalt = 1

        /**
         * The whole string, with the same grammar a `[...]` group gets — which is how Tidal is
         * built (`pPolyIn` is `brackets pTidal`), and why `bd|sd|cp` has to parse at the top level
         * and not only inside brackets.
         */
        fun parseTop(): Node {
            val node = parseRandom(stop = setOf())
            skipWs()
            if (!eof()) error("Unexpected '${peek()}' at $index")
            return node
        }

        /**
         * `a|b` — one alternative per cycle. Looser than `,`, so `a, b | c` is a choice between the
         * stack `a, b` and `c`. Tidal does not decide this case: its `pTidal` takes a `,`-tail or a
         * `|`-tail but not both, so mixing the two separators at one level is a Tidal parse error
         * and any reading Field gives it is Field's own. Nesting the looser one outermost at least
         * means a string Tidal *does* accept means the same thing here.
         */
        private fun parseRandom(stop: Set<Char>): Node {
            val alts = mutableListOf<Node>()
            do {
                alts += parseStack(stop + '|')
                skipWs()
            } while (match('|'))
            return when (alts.size) {
                0 -> Seq(emptyList())
                1 -> alts[0]
                else -> RandomChoice(alts, nextSalt++)
            }
        }

        private fun parseStack(stop: Set<Char>): Node {
            val layers = mutableListOf<Node>()
            do {
                layers += parseSeq(stop + ',')
                skipWs()
            } while (match(','))
            return when (layers.size) {
                0 -> Seq(emptyList())
                1 -> layers[0]
                else -> Stk(layers)
            }
        }

        /**
         * A sequence of terms, with the three standalone step markers Tidal allows between them:
         *
         *  - `.` groups what precedes it against what follows: `bd sd . hh hh hh` is
         *    `[bd sd] [hh hh hh]`, two equal halves rather than five equal fifths;
         *  - `_` extends the previous step by one slot: `bd _ _ sd` is `bd@3 sd`.
         *
         * A bare `!` — `bd ! !` for `bd bd bd` — needs nothing here: [parseTerm]'s postfix loop
         * already skips whitespace before an operator, so it sees each `!` itself. What it needed was
         * for repeats to *accumulate* instead of nest; see `accumulateRepeat`.
         *
         * Both are only markers when they stand alone — followed by whitespace, the end of the
         * string, or a closing/separating character. That is what keeps `0.5`, `my_atom`, `bd!2` and
         * `g:(0,0.12)` parsing exactly as they did before: their `.`, `_` and `!` are inside a token,
         * so `readAtom` consumes them and this loop never sees them.
         */
        private fun parseSeq(stop: Set<Char>): Node {
            val groups = mutableListOf(mutableListOf<Node>())
            while (true) {
                skipWs()
                if (eof() || peekIn(stop) || peek() == ',') break
                when {
                    isStandaloneMarker('.') -> {
                        index++
                        groups += mutableListOf<Node>()
                    }
                    isStandaloneMarker('_') -> {
                        index++
                        groups.last().let { g -> if (g.isNotEmpty()) g[g.size - 1] = elongateOnce(g.last()) }
                    }
                    else -> groups.last() += parseTerm(stop)
                }
                skipWs()
                if (eof() || peekIn(stop) || peek() == ',') break
            }
            val used = groups.filter { it.isNotEmpty() }
            // One group is the pre-existing path, byte for byte: no `.` means no behaviour change.
            if (used.size <= 1) {
                val terms = used.firstOrNull() ?: emptyList<Node>()
                return when (terms.size) {
                    0 -> Seq(emptyList())
                    1 -> terms[0]
                    else -> Seq(terms)
                }
            }
            return Seq(used.map { group -> if (group.size == 1) group[0] else Seq(group) })
        }

        /** True when [marker] is at the cursor *and* stands alone rather than opening a token. */
        private fun isStandaloneMarker(marker: Char): Boolean {
            if (eof() || src[index] != marker) return false
            val next = src.getOrNull(index + 1) ?: return true
            return next.isWhitespace() || next in STANDALONE_FOLLOWERS
        }

        private fun accumulateRepeat(node: Node, count: Int): Node {
            val extra = (count.coerceAtLeast(1) - 1)
            return when (node) {
                is Repeat -> node.copy(times = node.times.coerceAtLeast(1) + extra)
                else -> Repeat(node, count.coerceAtLeast(1))
            }
        }

        private fun elongateOnce(node: Node): Node = when (node) {
            is Elongate -> node.copy(steps = node.steps.coerceAtLeast(1) + 1)
            else -> Elongate(node, 2)
        }



        private fun parseTerm(stop: Set<Char>): Node {
            var node = parseFactor(stop)
            while (true) {
                skipWs()
                when {
                    match('(') -> {
                        skipWs()
                        // The bracket suffix means "impose a rhythm of this shape on the term". A
                        // leading letter says which family: nothing for Euclid, `k`/`g` for the two
                        // Kolakoski readings. Both then take an optional trailing offset.
                        val reading = kolakoskiMarker()
                        node = if (reading == null) {
                            val pulses = readRequiredInt("Expected Euclidean pulse count at $index")
                            skipWs()
                            expect(',')
                            val steps = readRequiredInt("Expected Euclidean step count at $index")
                            skipWs()
                            val offset = if (match(',')) readRequiredInt("Expected Euclidean offset at $index") else 0
                            skipWs()
                            expect(')')
                            Euclid(node, pulses, steps, offset)
                        } else {
                            val steps = readRequiredInt("Expected Kolakoski step count at $index")
                            skipWs()
                            val offset = if (match(',')) readRequiredInt("Expected Kolakoski offset at $index") else 0
                            skipWs()
                            expect(')')
                            Kolakoski(node, steps, reading, offset)
                        }
                    }
                    match('%') -> {
                        val subdivision = readRequiredInt("Expected polymetric subdivision at $index")
                        node = when (node) {
                            is Poly -> node.copy(subdivision = subdivision)
                            else -> error("% is only supported after polymetric { ... } forms at $index")
                        }
                    }
                    match('?') -> node = Degrade(node, readDouble() ?: 0.5, nextSalt++)
                    match('@') -> node = Elongate(node, readInt() ?: 2)
                    // Accumulate rather than nest. This loop already tolerates whitespace before a
                    // postfix operator, so `bd ! !` reaches here as two separate `!`s — and nesting
                    // them (`Repeat(Repeat(bd, 2), 2)`) gives FOUR copies where Tidal gives three.
                    // Each `!n` adds n-1 more copies of what is already there.
                    match('!') -> node = accumulateRepeat(node, readInt() ?: 2)
                    match('*') -> node = Fast(node, readInt() ?: 2)
                    match('/') -> node = Slow(node, readInt() ?: 2)
                    else -> return node
                }
            }
        }

        private fun parseFactor(stop: Set<Char>): Node {
            skipWs()
            return when {
                match('[') -> {
                    val inner = parseRandom(stop + ']')
                    expect(']')
                    inner
                }
                match('<') -> {
                    val alts = mutableListOf<Node>()
                    while (true) {
                        skipWs()
                        if (match('>')) break
                        alts += parseTerm(stop + '>')
                    }
                    Choice(alts)
                }
                peek() == '{' && index + 1 < src.length && src[index + 1] == '{' -> {
                    readExpr()
                }
                match('{') -> {
                    val parts = mutableListOf<Node>()
                    while (true) {
                        skipWs()
                        if (match('}')) break
                        parts += parseSeq(stop + setOf('}', ','))
                        skipWs()
                        match(',')
                    }
                    Poly(parts)
                }
                match('~') -> Rest
                else -> {
                    val atom = readAtom()
                    if (atom == null) error("Unexpected '${peek()}' at $index")
                    atom
                }
            }
        }

        private fun skipWs() {
            while (!eof() && src[index].isWhitespace()) index++
        }

        private fun eof(): Boolean = index >= src.length

        private fun peek(): Char = src[index]

        private fun peekIn(set: Set<Char>): Boolean = !eof() && set.contains(src[index])

        private fun match(c: Char): Boolean =
            if (!eof() && src[index] == c) {
                index++
                true
            } else {
                false
            }

        private fun expect(c: Char) {
            if (!match(c)) error("Expected '$c' at $index")
        }

        private fun readInt(): Int? {
            skipWs()
            val start = index
            while (!eof() && src[index].isDigit()) index++
            return if (index > start) src.substring(start, index).toInt() else null
        }

        private fun readRequiredInt(message: String): Int = readInt() ?: error(message)

        /**
         * Consumes the `k`/`g` that turns a `(…)` suffix from Euclidean into Kolakoski, and says
         * which reading it asked for. Null — and nothing consumed — for a plain `(3,8)`.
         */
        private fun kolakoskiMarker(): KolakoskiReading? {
            if (eof()) return null
            val reading = when (src[index].lowercaseChar()) {
                'k' -> KolakoskiReading.RUNS
                'g' -> KolakoskiReading.GAPS
                else -> return null
            }
            index++
            return reading
        }

        /** A bare decimal immediately after a modifier, as in `bd?0.3`. No leading whitespace. */
        private fun readDouble(): Double? {
            val start = index
            while (!eof() && (src[index].isDigit() || src[index] == '.')) index++
            if (index <= start) return null
            return src.substring(start, index).toDoubleOrNull()
        }

        private fun readAtom(): Atom? {
            val start = index
            while (!eof()) {
                val ch = src[index]
                val ok = ch.isLetterOrDigit() || ch == '_' || ch == '-' || ch == ':' || ch == '.' ||
                    ch == '+' || ch == '\''
                if (!ok) break
                index++
                if (ch == ':' && !eof() && src[index] == '(') {
                    readBalancedAtomParens()
                }
            }
            if (index <= start) return null
            return Atom(
                src.substring(start, index),
                PatSourceSpan(parseContext, start, index)
            )
        }

        private fun readBalancedAtomParens() {
            var depth = 0
            while (!eof()) {
                val ch = src[index]
                index++
                when (ch) {
                    '(' -> depth++
                    ')' -> {
                        depth--
                        if (depth <= 0) return
                    }
                }
            }
        }

        private fun readExpr(): Expr {
            val outerStart = index
            index += 2 // skip opening {{
            val bodyStart = index
            var depth = 1
            while (index + 1 < src.length) {
                if (src[index] == '{' && src[index + 1] == '{') {
                    depth++
                    index += 2
                    continue
                }
                if (src[index] == '}' && src[index + 1] == '}') {
                    depth--
                    if (depth == 0) {
                        val body = src.substring(bodyStart, index)
                        val span = PatSourceSpan(parseContext, outerStart, index + 2)
                        index += 2 // skip closing }}
                        return Expr(body, span)
                    }
                    index += 2
                    continue
                }
                index++
            }
            error("Unterminated {{...}} expression starting at $outerStart")
        }
    }

    private fun compileNode(node: Node): PatPattern<String> = when (node) {
        is Atom -> compileAtom(node)
        is Expr -> compileExpr(node)
        is Rest -> silence()
        is Seq -> when (node.items.size) {
            0 -> silence()
            1 -> compileNode(node.items[0])
            else -> compileSeq(node.items)
        }
        is Stk -> when (node.layers.size) {
            0 -> silence()
            1 -> compileNode(node.layers[0])
            else -> stack(*node.layers.map(::compileNode).toTypedArray())
        }
        // `<a b>` is one alternative per cycle, which is exactly [slowcat].
        is Choice -> slowcat(*node.alts.map(::compileNode).toTypedArray())
        is RandomChoice -> {
            val compiled = node.alts.map(::compileNode)
            fromCycleIndexed { subArc, tile ->
                if (compiled.isEmpty()) {
                    emptyList()
                } else {
                    val base = PatQ.of(tile)
                    val absSub = subArc.shift(base)
                    val idx = floorMod(splitmix64(tile xor (node.salt.toLong() shl 17)), compiled.size)
                    compiled[idx].events(absSub).map { it.shift(-base) }
                }
            }
        }
        is Poly -> compilePoly(node)
        is Repeat -> {
            val times = node.times.coerceAtLeast(0)
            val body = compileNode(node.expr)
            if (times <= 0) silence() else cat(*Array(times) { body })
        }
        is Fast -> compileNode(node.expr).fast(node.n.coerceAtLeast(1))
        is Slow -> compileNode(node.expr).slow(node.n.coerceAtLeast(1))
        is Elongate -> compileNode(node.expr)
        is Degrade -> compileNode(node.expr).degradeBy(node.probability, node.salt)
        is Euclid -> {
            val pulses = node.pulses.coerceAtLeast(0)
            val steps = node.steps.coerceAtLeast(1)
            euclidFill(pulses, steps, rotation = -node.offset, on = compileNode(node.expr))
        }
        // Same offset sense as `Euclid`: a positive offset in the source rotates the figure earlier.
        is Kolakoski -> kolakoskiFill(
            node.steps.coerceAtLeast(1),
            node.reading,
            rotation = -node.offset,
            on = compileNode(node.expr)
        )
    }

An atom becomes a one-event-per-cycle pattern. A trailing :n — the sample-index suffix — is additionally surfaced as an n annotation, so pat("bd:3") is both the atom bd:3 that every existing caller already sees and an event carrying n = 3 for the parameter algebra. Nothing is taken away; use dropSampleIndex if you want the bare name.

source — 130 lines, from line 1573
    private fun compileAtom(node: Atom): PatPattern<String> {
        val index = patSampleIndexSuffix(node.name)
        if (index == null) return pure(node.name, node.sourceSpan)
        val annotations = mapOf("n" to index.toString())
        return fromCycle { subArc ->
            val inter = PatArc(PatQ.ZERO, PatQ.ONE).intersect(subArc) ?: return@fromCycle emptyList()
            // A whole-cycle atom, exactly like `pure` — clipped for the query, whole for the player.
            listOf(PatEvent(inter, node.name, node.sourceSpan, annotations, whole = PAT_CYCLE))
        }
    }

    private fun compileExpr(node: Expr): PatPattern<String> {
        val body = node.body
        val span = node.sourceSpan
        return fromCycleIndexed { subArc, tile ->
            val base = PatQ.of(tile)
            val inter = PatArc(PatQ.ZERO, PatQ.ONE).intersect(subArc) ?: return@fromCycleIndexed emptyList()
            val midT = ((inter.start + inter.end) / PatQ.of(2)).toDouble()
            val value = PatExprEvaluator.evaluate(body, cycle = tile, t = midT)
                ?: return@fromCycleIndexed emptyList() // failed expression → silence
            listOf(PatEvent(inter, value, span, whole = PAT_CYCLE))
        }
    }

    private fun compileSeq(items: List<Node>): PatPattern<String> {
        val expanded = expandReplicatedItems(items)
        if (expanded.isEmpty()) return silence()
        val totalSlots = expanded.sumOf(::slotWidth).coerceAtLeast(1)
        return fromCycleIndexed { subArc, tile ->
            val base = PatQ.of(tile)
            val absSub = subArc.shift(base)
            val out = mutableListOf<PatEvent<String>>()
            var cursor = 0
            for (item in expanded) {
                val width = slotWidth(item).coerceAtLeast(1)
                val segment = PatArc(
                    PatQ.of(cursor, totalSlots),
                    PatQ.of(cursor + width, totalSlots)
                )
                val windowAbs = PatArc(base + segment.start, base + segment.end)
                val interAbs = absSub.intersect(windowAbs)
                if (interAbs != null) {
                    out += within(segment, compileNode(sequenceBody(item))).events(interAbs).map { it.shift(-base) }
                }
                cursor += width
            }
            out
        }
    }

    private fun compilePoly(node: Poly): PatPattern<String> {
        val layers = node.parts.filterNot(::isSilentSeq)
        if (layers.isEmpty()) return silence()
        val pulseCount = (node.subdivision ?: slotsForLayer(layers.first())).coerceAtLeast(1)
        return stack(*layers.map { compilePolyLayer(it, pulseCount) }.toTypedArray())
    }

    private fun compilePolyLayer(layer: Node, pulseCount: Int): PatPattern<String> {
        val items = expandLayerItems(layer)
        if (items.isEmpty()) return silence()
        val totalSlots = items.sumOf(::slotWidth).coerceAtLeast(1)
        val slotStarts = IntArray(items.size)
        run {
            var cursor = 0
            items.forEachIndexed { index, item ->
                slotStarts[index] = cursor
                cursor += slotWidth(item).coerceAtLeast(1)
            }
        }
        val maxWidth = maxSlotWidth(items)
        return PatPattern { absArc ->
            val startPulse = qFloor(absArc.start * PatQ.of(pulseCount)) - maxWidth
            val endPulse = qCeil(absArc.end * PatQ.of(pulseCount))
            val out = mutableListOf<PatEvent<String>>()
            items.forEachIndexed { index, item ->
                val width = slotWidth(item).coerceAtLeast(1)
                val startMod = slotStarts[index].toLong()
                val repetitionStart = ceilDiv(startPulse - startMod, totalSlots.toLong())
                val repetitionEnd = floorDiv(endPulse - startMod - 1L, totalSlots.toLong())
                for (repetition in repetitionStart..repetitionEnd) {
                    val globalStartSlot = startMod + repetition * totalSlots.toLong()
                    val window = PatArc(
                        PatQ.of(globalStartSlot, pulseCount.toLong()),
                        PatQ.of(globalStartSlot + width.toLong(), pulseCount.toLong())
                    )
                    val inter = absArc.intersect(window) ?: continue
                    // `patFocus`, not `within`: these windows are ABSOLUTE and run off past cycle 1,
                    // while `within` reads its window as a local, inside-one-cycle arc. That is why
                    // polymeter used to fall silent after cycle 0 — see patFocus's KDoc.
                    out += patFocus(window, repetition, compileNode(sequenceBody(item))).events(inter)
                }
            }
            out
        }
    }

    private fun expandReplicatedItems(items: List<Node>): List<Node> = buildList {
        items.forEach { item ->
            if (item is Repeat) {
                repeat(item.times.coerceAtLeast(0)) {
                    add(item.expr)
                }
            } else {
                add(item)
            }
        }
    }

    private fun expandLayerItems(node: Node): List<Node> = when (node) {
        is Seq -> expandReplicatedItems(node.items)
        else -> expandReplicatedItems(listOf(node))
    }

    private fun slotsForLayer(node: Node): Int = expandLayerItems(node).sumOf(::slotWidth).coerceAtLeast(1)

    private fun maxSlotWidth(items: List<Node>): Long =
        items.maxOfOrNull { slotWidth(it).coerceAtLeast(1) }?.toLong() ?: 1L

    private fun slotWidth(node: Node): Int = when (node) {
        is Elongate -> node.steps.coerceAtLeast(1)
        else -> 1
    }

    private fun sequenceBody(node: Node): Node = when (node) {
        is Elongate -> node.expr
        else -> node
    }

    private fun isSilentSeq(node: Node): Boolean = node is Seq && node.items.isEmpty()
}

object PatExprContext

Thread-local context for evaluating {{...}} expressions inside patterns. Expression bodies can read cycle (0-based cycle index) and t (fractional position inside the queried arc, 0.0..1.0).

internal val current = ThreadLocal<PatExprFrame?>()

val cycle: Long get() = current.get()?.cycle ?: 0L

The 0-based cycle index of the current query.

val t: Double get() = current.get()?.t ?: 0.0

Fractional position inside the queried sub-arc (0.0 .. 1.0).

internal data class PatExprFrame(val cycle: Long, val t: Double)

private inline fun <T> withPatExprFrame(cycle: Long, t: Double, block: () -> T): T {
    val prev = PatExprContext.current.get()
    PatExprContext.current.set(PatExprFrame(cycle, t))
    return try {
        block()
    } finally {
        PatExprContext.current.set(prev)
    }
}

Compiles and caches Kotlin expressions that appear inside {{...}} tokens in pattern strings. Each unique expression body is compiled once; evaluations receive PatExprContext via a ThreadLocal so the expression can reference PatExprContext.cycle and PatExprContext.t.

source — 51 lines, from line 1737
private object PatExprEvaluator {
    private val lock = Any()
    private val compiled = ConcurrentHashMap<String, PreparedSnippet>()
    private val compiler = KotlinInterface(filePrefix = "PAT_EXPR_")

    fun evaluate(body: String, cycle: Long, t: Double): String? {
        val source = buildSource(body)
        val snippet = synchronized(lock) {
            compiled[source] ?: runBlocking {
                compiler.compileSnippet(source, implicits = emptyList(), onCompileError = {
                    lprintln("error,pat", "pat expression compile error: $it")
                })
            }?.also { compiled[source] = it }
        }
        if (snippet == null) {
            lprintln("error,pat", "pat expression failed to compile: $body")
            return null
        }
        return withPatExprFrame(cycle, t) {
            try {
                val result = snippet.evalAndWait(
                    updateContext = false,
                    implicits = emptyList(),
                    onEvalError = { lprintln("error,pat", "pat expression eval error: $it") },
                    onEvalException = { lprintln("error,pat", "pat expression eval exception: $it") }
                )
                when (result) {
                    is ResultValue.Value -> result.value?.toString()
                    is ResultValue.Error -> {
                        lprintln("error,pat", "pat expression runtime error: ${result.error}")
                        null
                    }
                    else -> null
                }
            } catch (ex: Throwable) {
                lprintln("error,pat", "pat expression threw: ${ex.message}")
                null
            }
        }
    }

    private fun buildSource(body: String): String = """
        import kotlin.math.*
        import afield.audio.synth.PatExprContext
        run {
            val cycle = PatExprContext.cycle
            val t = PatExprContext.t
            $body
        }
    """.trimIndent()
}

fun patSampleIndexSuffix(name: String): Int?

The integer after the last : of an atom name, when the whole tail is digits — mini-notation's sample-index suffix. "bd:3" is 3; "bd", "g:(0,0.1)" and "808:x" are all null.

fun patSampleIndexSuffix(name: String): Int? {
    val colon = name.lastIndexOf(':')
    if (colon <= 0 || colon == name.length - 1) return null
    val tail = name.substring(colon + 1)
    if (!tail.all { it.isDigit() }) return null
    return tail.toIntOrNull()
}

fun PatPattern<String>.dropSampleIndex(): PatPattern<String>

Strips a trailing :n sample-index suffix from every atom, leaving the n annotation intact.

fun PatPattern<String>.dropSampleIndex(): PatPattern<String> = PatPattern { arc ->
    events(arc).map { event ->
        val colon = event.value.lastIndexOf(':')
        if (patSampleIndexSuffix(event.value) == null) event
        else event.copy(value = event.value.substring(0, colon))
    }
}

fun pat(source: String): PatPattern<String>

fun pat(source: String): PatPattern<String> = MiniPat.parse(source, parseContext = currentPatParseContext(source))

fun <A> pat(source: String, atomMapper: (String) -> A): PatPattern<A> =

fun <A> pat(source: String, atomMapper: (String) -> A): PatPattern<A> =
    MiniPat.parse(source, parseContext = currentPatParseContext(source)).map(atomMapper)

fun <A> PatPattern<A>.peek(n: Int = 8, cps: Number = 1.0): List<PatScheduledEvent<A>>

Quick-inspect shorthand: pat("bd hh sd").peek() returns the first n scheduled events. Equivalent to .scheduled(cps).take(n).toList().

fun <A> PatPattern<A>.peek(n: Int = 8, cps: Number = 1.0): List<PatScheduledEvent<A>> =
    scheduled(cps = cps, clock = null).take(n).toList()

fun PatPattern<String>.notes

Map pattern atoms to notes using a simple pitch mapping. Each atom string is parsed as a MIDI pitch number. Non-numeric atoms are silently dropped.

pat("60 64 67 72").notes(cps = 2.0) — no lambda needed for pure MIDI patterns.

fun PatPattern<String>.notes(
    cps: Number = 1.0,
    clock: PatClock? = defaultPatClock(),
    basePitch: Int = 0,
    velocity: Number = 1.0,
    durationScale: Number = 0.95
): NoteProcess = asNotes(cps = cps, clock = clock) { event ->
    val pitch = event.value.toIntOrNull() ?: return@asNotes emptyList()
    listOf(
        ScheduledNote(
            start = event.start,
            duration = event.duration * durationScale.toDouble(),
            pitch = (basePitch + pitch).toFloat(),
            velocity = velocity.toFloat(),
            label = event.value
        )
    )
}

fun PatPattern<String>.degrees

Map pattern atoms as scale degrees relative to a root pitch. pat("0 3 5 7").degrees(root = 36, cps = 2.0) — each atom is added to root.

fun PatPattern<String>.degrees(
    root: Int = 60,
    cps: Number = 1.0,
    clock: PatClock? = defaultPatClock(),
    velocity: Number = 1.0,
    durationScale: Number = 0.95
): NoteProcess = notes(cps = cps, clock = clock, basePitch = root, velocity = velocity, durationScale = durationScale)

fun <A> PatPattern<A>.asNotes

fun <A> PatPattern<A>.asNotes(
    cps: Number = 1.0,
    clock: PatClock? = defaultPatClock(),
    historyRetentionSeconds: Double = 8.0,
    defaultPastSeconds: Double = 1.0,
    defaultFutureSeconds: Double = 6.0,
    mapper: (PatScheduledEvent<A>) -> Iterable<ScheduledNote>
): NoteProcess = NoteProcess(
    iteratorFactory = {
        val events = scheduled(cps = cps, clock = clock).iterator()
        object : Iterator<ScheduledNote> {
            private val pending = ArrayDeque<ScheduledNote>()

            private fun prime() {
                while (pending.isEmpty() && events.hasNext()) {
                    val mapped = mapper(events.next()).sortedBy { it.start }
                    pending.addAll(mapped)
                }
            }

            override fun hasNext(): Boolean {
                prime()
                return pending.isNotEmpty()
            }

            override fun next(): ScheduledNote {
                if (!hasNext()) throw NoSuchElementException("No more mapped notes")
                return pending.removeFirst()
            }
        }
    },
    historyRetentionSeconds = historyRetentionSeconds,
    defaultPastSeconds = defaultPastSeconds,
    defaultFutureSeconds = defaultFutureSeconds
).reset()

class PatResponseScope<A> internal constructor

class PatResponseScope<A> internal constructor(
    val box: Box,
    val pattern: PatPattern<A>,
    val cps: Double,
    val clock: PatClock?,
    val ahead: Double,
    val launchInstant: Instant,
    internal val launchNanoTime: Long = System.nanoTime()
) {

val launchElapsed: Double

    val launchElapsed: Double
        get() = (System.nanoTime() - launchNanoTime).toDouble() / 1_000_000_000.0

val now: Double

    val now: Double
        get() = clock?.now ?: launchElapsed

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "pattern" to pattern,
        "cps" to cps,
        "clock" to clock,
        "ahead" to ahead,
        "launchInstant" to launchInstant
    )
}

class PatResponseSpec<A> internal constructor

class PatResponseSpec<A> internal constructor(
    internal val pattern: PatPattern<A>,
    internal val cps: Double,
    internal val fromCycle: Long?,
    internal val dispatchOnMain: Boolean,
    internal val clock: PatClock?,
    internal val ahead: Double,
    internal val callback: PatResponseScope<A>.(PatScheduledEvent<A>) -> Unit
) {

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "pattern" to pattern,
        "cps" to cps,
        "fromCycle" to fromCycle,
        "dispatchOnMain" to dispatchOnMain,
        "clock" to clock,
        "ahead" to ahead
    )
}

private fun String.prettyQuoted(limit: Int): String = "\"${if (length <= limit) this else take(limit - 3) + "..."}\""

private fun Double.prettyNumber(): String = String.format(Locale.US, "%.4f", this).trimEnd('0').trimEnd('.')

internal fun defaultPatClock(): PatClock?

internal rather than private so the other Pat* files — and SonicPiPatterns' sonicPiLive — can spell the same default rather than each inventing one; a second answer to "which clock?" is how two patterns in one box end up on two timelines.

internal fun defaultPatClock(): PatClock? {
    return Box.context.get()?.patClock ?: musicClock()
}

internal fun <A> PatPattern<A>.mapEventsPerCycle

internal fun <A> PatPattern<A>.mapEventsPerCycle(
    transform: (cycle: Long, eventsLocal: List<PatEvent<A>>) -> List<PatEvent<A>>
): PatPattern<A> = fromCycleIndexed { subArc, tile ->
    val base = PatQ.of(tile)
    val absArc = subArc.shift(base)
    val eventsLocal = events(absArc).map { it.shift(-base) }
    transform(tile, eventsLocal)
}

internal fun qFloor(x: PatQ): Long

internal fun qFloor(x: PatQ): Long {
    val divRem = x.n.divideAndRemainder(x.d)
    val q = divRem[0]
    val r = divRem[1]
    return if (x.n.signum() < 0 && r != BigInteger.ZERO) q.subtract(BigInteger.ONE).toLong() else q.toLong()
}

internal fun qCeil(x: PatQ): Long

internal fun qCeil(x: PatQ): Long {
    val exact = x.n.mod(x.d) == BigInteger.ZERO
    return if (exact) x.n.divide(x.d).toLong() else qFloor(x) + 1
}

private fun floorDiv(n: Long, d: Long): Long {
    require(d > 0) { "Divisor must be positive" }
    val q = n / d
    val r = n % d
    return if (r != 0L && n < 0L) q - 1 else q
}

private fun ceilDiv(n: Long, d: Long): Long {
    require(d > 0) { "Divisor must be positive" }
    return -floorDiv(-n, d)
}

internal fun splitmix64(seed: Long): Long

internal fun splitmix64(seed: Long): Long {
    var z = seed + -0x61c8864680b583ebL
    z = (z xor (z ushr 30)) * -0x4b1b3c39ec3d2dcdL
    z = (z xor (z ushr 27)) * -0x441d67c49c2c49c9L
    return z xor (z ushr 31)
}

internal fun toUnitDouble(x: Long): Double

internal fun toUnitDouble(x: Long): Double {
    val value = x ushr 11
    return value.toDouble() / (1L shl 53).toDouble()
}

internal fun floorMod(value: Long, modulus: Int): Int

internal fun floorMod(value: Long, modulus: Int): Int {
    require(modulus > 0) { "Modulus must be positive" }
    val mod = value % modulus.toLong()
    return if (mod < 0L) (mod + modulus).toInt() else mod.toInt()
}

private fun parseArpIndexToken(token: String): Int? = token.trim().toIntOrNull()

internal fun hashQ(q: PatQ): Int = 31 * q.n.hashCode() + q.d.hashCode()

internal fun eventKey(cycle: Long, arc: PatArc, salt: Int): Long

The per-event hash degradeBy uses to decide what to drop.

Internal rather than private because unDegradeBy must be its exact complement: two independent implementations of "the same hash" would silently overlap or leave gaps, and sometimesEventBy would then double or drop notes.

internal fun eventKey(cycle: Long, arc: PatArc, salt: Int): Long {
    var h = 1469598103934665603L
    fun mix(i: Int) {
        h = (h xor i.toLong()) * 1099511628211L
    }
    mix(cycle.hashCode())
    mix(hashQ(arc.start))
    mix(hashQ(arc.end))
    mix(salt)
    return h
}

private fun <T> List<T>.rotateRight(steps: Int): List<T> {
    if (isEmpty()) return this
    val amount = ((steps % size) + size) % size
    if (amount == 0) return this
    val split = size - amount
    return subList(split, size) + subList(0, split)
}

private val PAT_HIGHLIGHT_EXTRA_COLORS = listOf("#f0a", "#0fa", "#fa0", "#a0f", "#0ff", "#ff0")

fun <A> Marker.patHighlight

body — 63 lines, from line 2036
fun <A> Marker.patHighlight(
    event: PatScheduledEvent<A>,
    key: String = "active",
    color: String = "#0af"
): PatScheduledEvent<A> {
    val hintLine = patHighlightHintLine()

    // Primary span
    val decorationKey = "__pat_highlight__${name}__$key"
    val span = event.sourceSpan
    if (span == null) {
        box.decorationState.remove(decorationKey)
    } else {
        val range = findPatDecorationRange(box, span, hintLine = span.lineHint ?: hintLine)
        if (range == null) {
            box.decorationState.remove(decorationKey)
        } else {
            val className = "patAtom_${name}_${key}".map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
            val css = """
                .${className} {
                    background: color-mix(in srgb, ${color} 28%, transparent);
                    border-bottom: 1px solid color-mix(in srgb, ${color} 92%, white);
                    box-shadow: inset 0 0 0 1px color-mix(in srgb, ${color} 45%, transparent);
                    border-radius: 3px;
                }
            """.trimIndent().replace("\n", " ")
            box.decorationState[decorationKey] = RawDecoration(
                range.lineStart, range.colStart, range.lineEnd, range.colEnd, className, mapOf(className to css)
            )
        }
    }

    // Additional spans (from arp, zip, etc.)
    val maxExtra = 6
    for (i in 0 until maxExtra) {
        val extraKey = "__pat_highlight__${name}__${key}__x$i"
        if (i < event.additionalSpans.size) {
            val extraSpan = event.additionalSpans[i]
            val extraRange = findPatDecorationRange(box, extraSpan, hintLine = extraSpan.lineHint ?: hintLine)
            if (extraRange != null) {
                val extraColor = PAT_HIGHLIGHT_EXTRA_COLORS[i % PAT_HIGHLIGHT_EXTRA_COLORS.size]
                val extraClassName = "patAtom_${name}_${key}_x$i".map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
                val extraCss = """
                    .${extraClassName} {
                        background: color-mix(in srgb, ${extraColor} 28%, transparent);
                        border-bottom: 1px solid color-mix(in srgb, ${extraColor} 92%, white);
                        box-shadow: inset 0 0 0 1px color-mix(in srgb, ${extraColor} 45%, transparent);
                        border-radius: 3px;
                    }
                """.trimIndent().replace("\n", " ")
                box.decorationState[extraKey] = RawDecoration(
                    extraRange.lineStart, extraRange.colStart, extraRange.lineEnd, extraRange.colEnd, extraClassName, mapOf(extraClassName to extraCss)
                )
            } else {
                box.decorationState.remove(extraKey)
            }
        } else {
            box.decorationState.remove(extraKey)
        }
    }

    return event
}

fun Marker.clearPatHighlight(key: String = "active")

body — 123 lines, from line 2100
fun Marker.clearPatHighlight(key: String = "active") {
    box.decorationState.remove("__pat_highlight__${name}__$key")
    for (i in 0 until 6) {
        box.decorationState.remove("__pat_highlight__${name}__${key}__x$i")
    }
}

private data class PatDecorationRange(
    val lineStart: Int,
    val colStart: Int,
    val lineEnd: Int,
    val colEnd: Int
)

private fun currentPatParseContext(source: String): PatParseContext {
    val box = Box.context.get() ?: return PatParseContext(source)
    val stack = Exception().stackTrace.toList()
    val relevantFrames = stack.filter { frame ->
        frame.lineNumber > 0 && (frame.fileName?.contains(box.id) == true)
    }.map { frame ->
        PatParseFrame(
            className = frame.className,
            methodName = frame.methodName,
            fileName = frame.fileName,
            lineNumber = frame.lineNumber
        )
    }
    val lineHint = locationForTextStringOnStack("pat(", box, st = stack.toTypedArray())?.line
        ?: relevantFrames.firstOrNull()?.lineNumber
    return PatParseContext(
        source = source,
        lineHint = lineHint,
        stackTrace = relevantFrames
    )
}

private fun Marker.patHighlightHintLine(): Int? =
    locationForTextStringOnStack(".patHighlight(", box)?.line

private fun findPatDecorationRange(box: Box, span: PatSourceSpan, hintLine: Int?): PatDecorationRange? {
    val anchor = locatePatSourceLiteral(box, span.context, hintLine) ?: return null
    val start = anchor.sourceStart + span.start
    val endExclusive = anchor.sourceStart + span.endExclusive
    return codeRangeFromAbsoluteOffsets(anchor.code, start, endExclusive)
}

private data class PatSourceAnchor(val code: String, val sourceStart: Int, val lineStart: Int)

private fun locatePatSourceLiteral(box: Box, context: PatParseContext, hintLine: Int?): PatSourceAnchor? {
    val code = box.code ?: return null
    val source = context.source
    if (source.isEmpty()) return null
    val occurrences = mutableListOf<Int>()
    var searchFrom = 0
    while (searchFrom <= code.length - source.length) {
        val found = code.indexOf(source, startIndex = searchFrom)
        if (found < 0) break
        occurrences += found
        searchFrom = found + 1
    }
    if (occurrences.isEmpty()) return null
    val frameLines = context.stackTrace
        .asSequence()
        .filter { it.lineNumber > 0 && (it.fileName?.contains(box.id) == true) }
        .map { it.lineNumber }
        .toList()
    val bestHint = hintLine ?: context.lineHint
    val chosen = occurrences.minWithOrNull(compareBy<Int> { occurrence ->
        val line = lineForAbsoluteOffset(code, occurrence)
        val stackDistance = frameLines.minOfOrNull { abs(it - line) } ?: Int.MAX_VALUE / 4
        val hintDistance = bestHint?.let { abs(it - line) } ?: stackDistance
        min(hintDistance, stackDistance)
    }.thenBy { occurrence ->
        val line = lineForAbsoluteOffset(code, occurrence)
        val stackDistance = frameLines.minOfOrNull { abs(it - line) } ?: Int.MAX_VALUE / 4
        val hintDistance = bestHint?.let { abs(it - line) } ?: stackDistance
        hintDistance + stackDistance
    }.thenBy { occurrence ->
        // Prefer occurrences that are exact string literals (bounded by quotes)
        val before = if (occurrence > 0) code[occurrence - 1] else ' '
        val after = if (occurrence + source.length < code.length) code[occurrence + source.length] else ' '
        if (before == '"' && after == '"') 0 else 1
    }.thenBy { it }) ?: return null
    return PatSourceAnchor(code = code, sourceStart = chosen, lineStart = lineForAbsoluteOffset(code, chosen))
}

private fun lineForAbsoluteOffset(code: String, offset: Int): Int {
    val safe = offset.coerceIn(0, code.length)
    var line = 1
    for (i in 0 until safe) {
        if (code[i] == '\n') line++
    }
    return line
}

private fun codeRangeFromAbsoluteOffsets(code: String, start: Int, endExclusive: Int): PatDecorationRange? {
    val safeStart = start.coerceIn(0, code.length)
    val safeEnd = endExclusive.coerceIn(safeStart, code.length)
    val effectiveEnd = if (safeEnd == safeStart && safeEnd < code.length) safeEnd + 1 else safeEnd
    val startLoc = lineColAt(code, safeStart)
    val endLoc = lineColAt(code, effectiveEnd)
    return PatDecorationRange(
        lineStart = startLoc.first,
        colStart = startLoc.second,
        lineEnd = endLoc.first,
        colEnd = endLoc.second
    )
}

private fun lineColAt(code: String, offset: Int): Pair<Int, Int> {
    val safe = offset.coerceIn(0, code.length)
    var line = 1
    var col = 1
    for (i in 0 until safe) {
        if (code[i] == '\n') {
            line++
            col = 1
        } else {
            col++
        }
    }
    return line to col
}