Skip to content

PetriMusic.kt

src/jvmMain/kotlin/afield/simulation/petri/PetriMusic.kt · 658 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")
@file:afield.runtime.completion.CompletionCategory("Audio")

package afield.simulation.petri

import afield.audio.synth.PatArc
import afield.audio.synth.PatEvent
import afield.audio.synth.PatPattern
import afield.audio.synth.PatQ
import afield.audio.synth.bjorklund
import afield.audio.synth.signalOf
import afield.audio.synth.squeezeJoin

Turns a Petri net into a PatPattern, so the whole pattern language applies to it.

A net is a pure function of time, and that is what lets it be a pattern. PatPattern is not a list of notes: it is a function from a time arc to the events inside it, and the combinators may call it with any arc, in any order, as many times as they like. rev() reads a cycle backwards, every(4) re-queries under a transform, and the scheduler walks forward one cycle at a time. A stateful sequencer cannot satisfy that contract. A Petri net with a deterministic conflict policy can, because PetriRun memoises an append-only trajectory: the answer for cycle 3 is the same object whether you ask before or after asking about cycle 9.

So nothing here reimplements a combinator:

val net = petri {
    place("kick", tokens = 1)
    t("hit",  dur = cyc(1, 16), emit = "bd") { from("kick"); to("hats") }
    t("back", dur = cyc(1, 16), emit = "sd") { from("hats"); to("kick") }
}

net.emits()                                  // PatPattern<String>
   .every(4) { it.fast(2) }                  // the rest of the language applies
   .jux { it.rev() }
   .respond(cps = 2.0) { println(it) }

Three ways from a net into the language

  1. Firings are events. pattern gives you PatPattern<PetriFiring>; emits narrows it to what each firing sounds. This is the short-term rhythm layer, where a net behaves like a drum machine.

  2. Tokens are patterns. A token's colour can be a whole PatPattern, so a place is a section and a transition is an edit. unfold plays whatever pattern a firing consumed across that firing's arc, which makes a slow net a song structure over the fast nets it schedules.

  3. Markings are control signals. signal turns a place's token count into a continuous PatPattern<Double>, optionally interpolated, so tension accumulating in a place can drive a filter cutoff: with cutoff(net.signal("tension").rangex(200, 8000)).

firings as events

fun PetriRun.pattern(): PatPattern<PetriFiring>

The net's firings as a pattern.

Each firing becomes one event spanning the firing's own arc — delay and duration already applied — clipped to whatever the query asked about, with the full arc kept as the event's PatEvent.whole so the scheduler strikes the note once, at its onset, at its real length.

Every event is annotated with petri (the transition name) and, when there is one, emit, so that information survives map, with, and the trip through PatScheduledEvent.

fun PetriRun.pattern(): PatPattern<PetriFiring> = PatPattern { arc ->
    firingsOver(arc).mapNotNull { firing ->
        val visible = firing.arc.intersect(arc) ?: return@mapNotNull null
        PatEvent(
            arc = visible,
            value = firing,
            annotations = buildMap {
                put("petri", firing.transition)
                firing.emit?.let { put("emit", it.toString()) }
            },
            whole = firing.arc,
        )
    }
        // Two transitions firing in the same maximal step land on the same instant, and the order
        // the engine happened to visit them in is not a musical fact. Sorting by name breaks the
        // tie the same way every time, so `rev()` and friends do not silently shuffle a chord.
        .sortedWith(compareBy({ it.arc.start }, { it.value.transition }))
}

fun PetriNet.pattern(from: PetriMarking = initial): PatPattern<PetriFiring>

The net's firings as a pattern, from a fresh PetriRun.

A run is the memo, so two calls give two independent (but identical) trajectories. Hold a run yourself and call PetriRun.pattern when you want several views onto the same performance — which is what you want if one of them is the visualizer.

fun PetriNet.pattern(from: PetriMarking = initial): PatPattern<PetriFiring> = run(from).pattern()

fun PatPattern<PetriFiring>.only(vararg transitions: String): PatPattern<PetriFiring>

Keeps only the firings of the named transitions — one lane out of a multi-track net.

fun PatPattern<PetriFiring>.only(vararg transitions: String): PatPattern<PetriFiring> {
    val wanted = transitions.toSet()
    return PatPattern { arc -> events(arc).filter { it.value.transition in wanted } }
}

fun PatPattern<PetriFiring>.withoutTransitions(vararg transitions: String): PatPattern<PetriFiring>

Drops the firings of the named transitions.

Not called without: the parameter algebra already has a PatPattern<A>.without(vararg keys) that removes parameters, and two same-named extensions on the same receiver doing different things is a trap even when overload resolution picks the right one.

fun PatPattern<PetriFiring>.withoutTransitions(vararg transitions: String): PatPattern<PetriFiring> {
    val unwanted = transitions.toSet()
    return PatPattern { arc -> events(arc).filter { it.value.transition !in unwanted } }
}

fun PatPattern<PetriFiring>.emits(): PatPattern<String>

What each firing sounds, as text — the door to notes(), the Dirt sample vocabulary, and everything else in the language that speaks PatPattern<String>.

Silent transitions (those with no emit) are dropped, not rendered as "null": a net's plumbing transitions exist to move tokens and should not turn into notes.

fun PatPattern<PetriFiring>.emits(): PatPattern<String> = PatPattern { arc ->
    events(arc).mapNotNull { event ->
        val emit = event.value.emit ?: return@mapNotNull null
        event.withValue(emit.toString())
    }
}

inline fun <reified T> PatPattern<PetriFiring>.emitsAs(): PatPattern<T>

emits for a typed emit — emitsAs<Int>() for note numbers. Firings emitting anything else are dropped.

inline fun <reified T> PatPattern<PetriFiring>.emitsAs(): PatPattern<T> = PatPattern { arc ->
    events(arc).mapNotNull { event ->
        val emit = event.value.emit as? T ?: return@mapNotNull null
        event.withValue(emit)
    }
}

fun PetriNet.emits(from: PetriMarking = initial): PatPattern<String> = pattern(from).emits()

The net's emitted sounds, straight from the net. The one-liner most nets want.

inline fun <reified T> PatPattern<PetriFiring>.consumed(): PatPattern<T>

The colours a firing consumed, one event per firing that consumed a T.

inline fun <reified T> PatPattern<PetriFiring>.consumed(): PatPattern<T> = PatPattern { arc ->
    events(arc).mapNotNull { event ->
        val colour = event.value.consumed.firstNotNullOfOrNull { it.colour as? T } ?: return@mapNotNull null
        event.withValue(colour)
    }
}

tokens that are patterns

enum class PetriUnfold

How unfold fits a pattern-valued token into the arc of the firing that consumed it.

enum class PetriUnfold {

Fit one whole cycle of the inner pattern into the firing's arc, Tidal's squeeze. A four-cycle firing plays the inner pattern four times slower. Good for ornaments, wrong for sections.

    SQUEEZE,

Let the inner pattern run at its own rate, in absolute time, and show only the part inside the firing's arc. A four-cycle firing plays four cycles of a one-cycle loop. This is the song-structure behaviour, and the default.

    NATURAL,

As NATURAL, but with the inner pattern re-phased so the firing's arc starts at the inner pattern's cycle 0 — the section starts at the top of the loop however the bar lines fell.

    RESTART,
}

inline fun <reified A> PatPattern<PetriFiring>.unfold

Plays the pattern each firing consumed, across that firing's arc.

This is the medium-term structure layer. A slow net whose places hold PatPattern tokens is an arrangement: each transition is a section change, each firing's duration is a section length, and unfold renders the whole thing back down to one flat pattern of notes.

val song = petri {
    timing = PetriTiming.TIMED
    place("verse", pat("bd ~ sd ~"))
    t("play-verse",  dur = cyc(8)) { from("verse");  to("chorus") }
    t("play-chorus", dur = cyc(8)) { from("chorus"); to("verse") }
}
song.pattern().unfold<String>()      // eight bars of each, forever

Firings whose consumed tokens hold no pattern contribute nothing — they are structure, not sound.

inline fun <reified A> PatPattern<PetriFiring>.unfold(
    mode: PetriUnfold = PetriUnfold.NATURAL,
): PatPattern<A> {
    val inners: PatPattern<PatPattern<A>> = PatPattern { arc ->
        events(arc).mapNotNull { event ->
            @Suppress("UNCHECKED_CAST")
            val inner = event.value.consumed
                .firstNotNullOfOrNull { it.colour as? PatPattern<*> } as? PatPattern<A>
                ?: return@mapNotNull null
            event.withValue(inner)
        }
    }
    return petriUnfoldJoin(inners, mode)
}

fun <A> petriUnfoldJoin(inners: PatPattern<PatPattern<A>>, mode: PetriUnfold): PatPattern<A>

The join behind unfold, separated out so the reified wrapper stays small.

fun <A> petriUnfoldJoin(inners: PatPattern<PatPattern<A>>, mode: PetriUnfold): PatPattern<A> =
    when (mode) {
        PetriUnfold.SQUEEZE -> squeezeJoin(inners)
        PetriUnfold.NATURAL, PetriUnfold.RESTART -> PatPattern { arc ->
            inners.events(arc).flatMap { outer ->
                val extent = outer.extent
                val visible = extent.intersect(arc) ?: return@flatMap emptyList()
                // NATURAL asks the inner pattern about absolute time; RESTART slides the section
                // back so its start is the inner pattern's cycle 0.
                val shift = if (mode == PetriUnfold.RESTART) extent.start else PatQ.ZERO
                val asked = PatArc(visible.start - shift, visible.end - shift)
                outer.value.events(asked).mapNotNull { inner ->
                    val placed = inner.arc.shift(shift).intersect(arc) ?: return@mapNotNull null
                    PatEvent(
                        arc = placed,
                        value = inner.value,
                        sourceSpan = inner.sourceSpan ?: outer.sourceSpan,
                        annotations = outer.annotations + inner.annotations,
                        additionalSpans = outer.additionalSpans + inner.additionalSpans,
                        // Clamp the inner note's extent to the section: a note may not sound past
                        // the transition that ended the section holding it.
                        whole = inner.whole?.shift(shift)?.intersect(extent),
                    )
                }
            }
        }
    }

markings as control signals

enum class PetriInterp

How signal fills in the space between the instants at which a place's token count changed.

enum class PetriInterp {

The raw step function. What the marking literally is.

    STEP,

Straight lines between changes — the count as a ramp.

    LINEAR,

Smoothstep between changes: no corners, so it is safe on a filter cutoff.

    SMOOTH,
}

fun PetriRun.signal(place: String, interpolate: PetriInterp = PetriInterp.STEP): PatPattern<Double>

A place's token count as a continuous signal.

Signals are the language's control-value currency, so this hands a net straight to the parameter algebra: a place that accumulates tokens as a section builds is a tension curve, and net.signal("tension").rangex(200, 8000) is a filter sweep driven by the structure rather than bolted alongside it.

The result is a genuine continuous pattern — one event per query, sampled at the arc's exact rational midpoint — so segment(16) discretises it and with gain(...) samples it per note, exactly as for afield.audio.synth.sine.

fun PetriRun.signal(place: String, interpolate: PetriInterp = PetriInterp.STEP): PatPattern<Double> =
    signalOf { exact, _ ->
        when (interpolate) {
            PetriInterp.STEP -> countAt(place, exact).toDouble()
            PetriInterp.LINEAR, PetriInterp.SMOOTH -> {
                val history = changes(place, exact + PatQ.ONE)
                val index = history.indexOfLast { it.first <= exact }
                if (index < 0) history.firstOrNull()?.second?.toDouble() ?: 0.0
                else {
                    val (t0, c0) = history[index]
                    val next = history.getOrNull(index + 1)
                    if (next == null) c0.toDouble()
                    else {
                        val (t1, c1) = next
                        val span = (t1 - t0).toDouble()
                        val raw = if (span <= 0.0) 0.0 else ((exact - t0).toDouble() / span).coerceIn(0.0, 1.0)
                        val u = if (interpolate == PetriInterp.SMOOTH) raw * raw * (3.0 - 2.0 * raw) else raw
                        c0 + (c1 - c0) * u
                    }
                }
            }
        }
    }

fun PetriNet.signal(place: String, interpolate: PetriInterp = PetriInterp.STEP): PatPattern<Double>

signal from a fresh run — convenient, but see PetriRun.pattern on sharing a run.

fun PetriNet.signal(place: String, interpolate: PetriInterp = PetriInterp.STEP): PatPattern<Double> =
    run().signal(place, interpolate)

fun PetriRun.level

A place's occupancy as a 0..1 signal — the form the shaping operators expect.

signal gives you the raw token count, which is the honest number but the wrong range: range(lo, hi) and rangex(lo, hi) map 0..1 onto their interval and extrapolate outside it, so feeding them a count of 3 sends a filter three times past the top of its sweep. Divide first:

with cutoff(net.level("tension", 4).rangex(200, 8000))

full defaults to the place's declared capacity, so a bounded place needs no second number. Values are clamped, because a signal that leaves 0..1 is almost always a mistake rather than an intention — reach for signal when you want the raw count.

fun PetriRun.level(
    place: String,
    full: Int? = net.place(place)?.capacity,
    interpolate: PetriInterp = PetriInterp.STEP,
): PatPattern<Double> {
    val ceiling = (full ?: 1).coerceAtLeast(1).toDouble()
    return signal(place, interpolate).let { raw -> PatPattern { arc -> raw.events(arc).map { it.withValue((it.value / ceiling).coerceIn(0.0, 1.0)) } } }
}

fun PetriNet.level

level from a fresh run.

fun PetriNet.level(
    place: String,
    full: Int? = place(place)?.capacity,
    interpolate: PetriInterp = PetriInterp.STEP,
): PatPattern<Double> = run().level(place, full, interpolate)

fun PetriRun.gate(place: String, atLeast: Int = 1): PatPattern<Double>

A signal that is 1.0 while place holds at least atLeast tokens and 0.0 otherwise — a gate.

Multiply a pattern's gain by this and a place becomes a mute button that the net itself operates, which is how a section net silences a rhythm net without either knowing about the other.

fun PetriRun.gate(place: String, atLeast: Int = 1): PatPattern<Double> =
    signalOf { exact, _ -> if (countAt(place, exact) >= atLeast) 1.0 else 0.0 }

nets you would otherwise write out by hand

fun petriRing

A Euclidean token ring: steps places in a circle, one token walking round them, and a sound at the pulses positions the Bjorklund algorithm picks out.

This is the beats-btw drum primitive, and it is here because it is genuinely the right net for a drum part — but note what you get that a hard-coded ring does not. It is a real net, so you can hang an inhibitor arc off one of its places to duck the kick, read a position to trigger something else, or merge it with another ring and let them contend for a token.

Place i is "$name.$i" and the transition leaving it is "$name.$i.go", so those names are what you wire against. They are deliberately spellable in the #petri notation, so a generated ring and a hand-written one are the same kind of thing.

fun petriRing(
    name: String,
    pulses: Int,
    steps: Int,
    rotation: Int = 0,
    emit: Any? = name,
    build: PetriBuilder.() -> Unit = {},
): PetriNet = petri {
    grid = cyc(1, maxOf(1, steps))
    petriRingInto(this, name, pulses, steps, rotation, emit)
    build()
}

fun petriRingInto

Adds a Euclidean ring to a net you are already building. See petriRing.

The multi-track form is just several calls:

val kit = petri {
    grid = cyc(1, 16)
    petriRingInto(this, "kick", 4, 16, emit = "bd")
    petriRingInto(this, "hat", 11, 16, emit = "hh")
}
fun petriRingInto(
    into: PetriBuilder,
    name: String,
    pulses: Int,
    steps: Int,
    rotation: Int = 0,
    emit: Any? = name,
) {
    val count = maxOf(1, steps)
    val mask = bjorklund(pulses.coerceIn(0, count), count).let { hits ->
        val r = ((rotation % count) + count) % count
        List(count) { index -> hits[(index + r) % count] }
    }
    with(into) {
        for (index in 0 until count) place("$name.$index", tokens = if (index == 0) 1 else 0)
        for (index in 0 until count) {
            t("$name.$index.go", dur = cyc(1, count), emit = if (mask[index]) emit else null) {
                from("$name.$index")
                to("$name.${(index + 1) % count}")
            }
        }
    }
}

fun petriSong(vararg sections: Pair<String, Pair<PatPattern<*>, Int>>, loop: Boolean = true): PetriNet

A linear song form: one place per section, one transition between consecutive sections, each holding for its own number of cycles, looping at the end.

The sections' patterns ride round as token colours, so unfold renders the arrangement:

petriSong(
    "intro" to (pat("hh*8") to 4),
    "verse" to (pat("bd ~ sd ~") to 8),
    "drop"  to (pat("bd*4") to 8),
).pattern().unfold<String>()
fun petriSong(vararg sections: Pair<String, Pair<PatPattern<*>, Int>>, loop: Boolean = true): PetriNet {
    require(sections.isNotEmpty()) { "A song needs at least one section" }
    return petri {
        timing = PetriTiming.TIMED
        for ((index, section) in sections.withIndex()) {
            val (sectionName, body) = section
            place(sectionName, colours = if (index == 0) listOf(body.first) else emptyList())
        }
        for ((index, section) in sections.withIndex()) {
            val (sectionName, body) = section
            val last = index == sections.lastIndex
            if (last && !loop) {
                t("play-$sectionName", dur = cyc(body.second)) { from(sectionName); to("$sectionName.done") }
            } else {
                val next = sections[(index + 1) % sections.size]
                t("play-$sectionName", dur = cyc(body.second)) {
                    from(sectionName)
                    // The token that leaves carries the *next* section's pattern, so the colour
                    // circulating round the ring is always what should be sounding next.
                    to(next.first) { binding -> listOf(binding.token(next.second.first)) }
                }
            }
        }
    }
}

the live runtime

class PetriLive internal constructor

A net you can reach into while it is playing.

PetriRun is the deterministic half of this file: a trajectory, memoised, queryable in any order, and therefore a PatPattern. This is the other half. A performance is not a pure function of time — someone drops a token in, forces a transition, mutes a voice — and none of that can live inside something the pattern combinators are allowed to re-query.

So the two are deliberately separate, and the seam is honest:

  • PetriRun is what you compose with. every, jux, off, with gain(…) — all of it.
  • PetriLive is what you perform with. inject, remove, force, and a picture that changes under your hands via Box.showPetriNet.

Internally a live net is a run — the same engine, so the same semantics — that gets thrown away and rebuilt from the current marking whenever you interfere with it. One consequence worth knowing: under PetriTiming.TIMED, interfering discards whatever was in flight at that moment. That is the right answer for a performance (you changed the net; the old net's half-finished business is not owed anything) but it is a real behaviour, not an accident.

class PetriLive internal constructor(
    val net: PetriNet,
    from: PetriMarking = net.initial,
    startAt: PatQ = PatQ.ZERO,
) {
    private val lock = Any()
    private var run: PetriRun = net.run(from, startAt)
    private var consumedSteps = 0
    private var firingCounter = 0L

var marking: PetriMarking

The marking right now.

    @Volatile
    var marking: PetriMarking = from
        private set

var time: PatQ

How far the net has been advanced.

    @Volatile
    var time: PatQ = startAt
        private set

var clickRemoves: Boolean

Whether clicking an occupied unbounded place in the visualizer takes a token out rather than putting another in. Off by default, because adding is the more common gesture.

    @Volatile
    var clickRemoves: Boolean = false

val listeners: MutableList<(PetriFiring) -> Unit> = java.util.concurrent.CopyOnWriteArrayList()

Called for every firing, whether it came from the clock or from force.

fun onFiring(listener: (PetriFiring) -> Unit): () -> Unit {

Registers listener and returns a function that removes it again.

    fun onFiring(listener: (PetriFiring) -> Unit): () -> Unit {
        listeners += listener
        return { listeners -= listener }
    }

fun advanceTo(instant: PatQ): List<PetriFiring>

Runs the net forward to instant, returning everything that fired on the way.

    fun advanceTo(instant: PatQ): List<PetriFiring> {
        val fired = synchronized(lock) {
            val out = mutableListOf<PetriFiring>()
            while (true) {
                val steps = run.steps(consumedSteps + 1)
                if (steps.size <= consumedSteps) break
                val step = steps[consumedSteps]
                if (step.at > instant) break
                consumedSteps++
                marking = step.after
                time = step.at
                out += step.firings
            }
            out
        }
        fired.forEach { firing -> listeners.forEach { it(firing) } }
        return fired
    }

fun inject(place: String, colour: Any? = null, count: Int = 1): PetriLive

Puts a token of colour into place, now.

    fun inject(place: String, colour: Any? = null, count: Int = 1): PetriLive = apply {
        synchronized(lock) {
            require(net.place(place) != null) { "No place called '$place' in this net" }
            rebuildLocked(marking.add(place, List(maxOf(1, count)) { PetriToken(colour, time) }))
        }
    }

fun remove(place: String, count: Int = 1): PetriLive

Takes the oldest token out of place, if there is one.

    fun remove(place: String, count: Int = 1): PetriLive = apply {
        synchronized(lock) {
            val present = marking[place]
            if (present.isEmpty()) return@synchronized
            rebuildLocked(marking.withPlace(place, present.drop(maxOf(1, count))))
        }
    }

fun clear(place: String): PetriLive

Empties place.

    fun clear(place: String): PetriLive = apply {
        synchronized(lock) { rebuildLocked(marking.withPlace(place, emptyList())) }
    }

fun force(transition: String): PetriFiring?

Fires transition right now, whether or not the clock was going to, and returns the firing — or null if it is not enabled, in which case nothing happened.

This is the performance gesture the classical model has no word for: the net's logic still has to allow it (a transition with no tokens cannot be forced) but its timing is yours.

    fun force(transition: String): PetriFiring? {
        val firing = synchronized(lock) {
            val spec = net.transition(transition) ?: return null
            val bound = petriBind(spec, marking, marking, time) ?: return null
            val produced = petriProduce(spec, bound.binding)
            var next = bound.consumeFrom(marking)
            for ((place, tokens) in produced) next = next.add(place, tokens)

            val start = time + spec.delay
            val length = if (spec.duration > PatQ.ZERO) spec.duration else net.grid
            rebuildLocked(next)
            PetriFiring(
                transition = spec.name,
                at = time,
                arc = afield.audio.synth.PatArc(start, start + length),
                cycle = petriCycleOf(time),
                consumed = bound.binding.consumed,
                produced = produced.flatMap { it.second },
                emit = spec.emitOf?.invoke(bound.binding) ?: spec.emit,
                index = firingCounter++,
            )
        }
        listeners.forEach { it(firing) }
        return firing
    }

fun reset(to: PetriMarking = net.initial): PetriLive

Back to the marking it started from, at time zero.

    fun reset(to: PetriMarking = net.initial): PetriLive = apply {
        synchronized(lock) {
            time = PatQ.ZERO
            rebuildLocked(to)
        }
    }

fun frame(layout: PetriLayout = PetriLayout.AUTO, radius: Double = 240.0): PetriDrawing

A frame for the visualizer: the marking now, and what it enables.

    fun frame(layout: PetriLayout = PetriLayout.AUTO, radius: Double = 240.0): PetriDrawing =
        PetriDrawing(net, marking, net.enabled(marking, time).toSet(), emptySet(), time, layout, radius = radius)

Throws away the memoised future and starts a fresh one from next at the current instant.

    private fun rebuildLocked(next: PetriMarking) {
        marking = next
        run = net.run(next, time)
        consumedSteps = 0
    }

override fun toString(): String = "PetriLive(@$time, $marking)"

private fun petriCycleOf(at: PatQ): Long {
    val (quotient, remainder) = at.n.divideAndRemainder(at.d)
    return (if (remainder.signum() < 0) quotient.subtract(java.math.BigInteger.ONE) else quotient).toLong()
}

fun PetriNet.live(from: PetriMarking = initial, startAt: PatQ = PatQ.ZERO): PetriLive

A live, pokeable instance of this net. See PetriLive.

fun PetriNet.live(from: PetriMarking = initial, startAt: PatQ = PatQ.ZERO): PetriLive =
    PetriLive(this, from, startAt)

fun PetriLive.play

Plays a live net in a box, on the beat.

The clock work is not reimplemented here: a pure pattern of grid ticks goes through the language's own respond, which already knows about strands, run generations, clock pauses and beat alignment, and each tick advances the live net and hands you whatever fired. So a live net starts, stops and re-evaluates with exactly the same behaviour as any other pattern in a box.

val live = net.live()
live.play(cps = 2.0) { firing -> firing.emit?.let { dirt(it.toString()) } }

The net is sampled on its own PetriNet.grid, or finer if some transition is shorter than that, so a firing is never missed between ticks.

fun PetriLive.play(
    cps: Number = 1.0,
    clock: afield.audio.synth.PatClock? = afield.audio.synth.musicClock(),
    ahead: Number = 0.0,
    dispatchOnMain: Boolean = true,
    callback: (PetriFiring) -> Unit = {},
): afield.audio.synth.PatResponseSpec<Unit> {
    val finest = net.transitions
        .mapNotNull { it.duration.takeIf { d -> d > PatQ.ZERO } }
        .minOrNull()
        ?.let { if (it < net.grid) it else net.grid }
        ?: net.grid
    return petriTicks(finest).respond(
        cps = cps,
        clock = clock,
        ahead = ahead,
        dispatchOnMain = dispatchOnMain,
    ) { event ->
        advanceTo(event.arc.start).forEach(callback)
    }
}

fun petriTicks(step: PatQ = PatQ.of(1, 16)): PatPattern<Unit>

A pure pattern of one silent event per step of a cycle — the metronome a live net is driven by.

Deliberately trivial and deliberately pure: all the statefulness lives in the net, and the thing carrying it to the scheduler is something the scheduler already understands.

fun petriTicks(step: PatQ = PatQ.of(1, 16)): PatPattern<Unit> {
    require(step > PatQ.ZERO) { "A tick has to be longer than nothing" }
    return PatPattern { arc ->
        val out = mutableListOf<afield.audio.synth.PatEvent<Unit>>()
        var index = java.math.BigInteger.ZERO.max((arc.start / step).let { floorQ(it) })
        // Start one step behind the window so a tick that began just before it is still offered.
        var cursor = step * PatQ(index, java.math.BigInteger.ONE)
        while (cursor < arc.start) {
            index = index.add(java.math.BigInteger.ONE)
            cursor = step * PatQ(index, java.math.BigInteger.ONE)
        }
        while (cursor < arc.end) {
            val cell = PatArc(cursor, cursor + step)
            cell.intersect(arc)?.let { visible ->
                out += afield.audio.synth.PatEvent(visible, Unit, whole = cell)
            }
            index = index.add(java.math.BigInteger.ONE)
            cursor = step * PatQ(index, java.math.BigInteger.ONE)
        }
        out
    }
}

private fun floorQ(q: PatQ): java.math.BigInteger {
    val (quotient, remainder) = q.n.divideAndRemainder(q.d)
    return if (remainder.signum() < 0) quotient.subtract(java.math.BigInteger.ONE) else quotient
}