A Petri net that throws sparks
A Petri net can play a drum part and throw sparks at the same time. Each time a transition fires in this example, you hear a sample from Dirt-Samples (the drum library TidalCycles uses) and see a burst of particles leave the place its token came from.
A box connects the net to the GPU particle system: it asks what has fired recently and writes the answer into a buffer for the shader.

The three rings are the net's three token rings, drawn from the centre out: the snare's three
places, the kick's four and the hats' five. Each bright comet is a ring's token walking clockwise
from place to place. A fresh burst is still a tight white-hot ball; an older one has
spread, taken on its transition's colour and begun to fall. The clap, which fires when four kicks
have filled a place called charge, throws a wider magenta spray from the centre.
The net
Three rings of places share one eighth-note grid. A ring of n places brings its token back to the
start every n eighths, so the kick, snare and hats run at three different periods and drift
against each other. The kick also drops a token into charge every lap; charge holds at most
four, and the transition that takes all four out at once is the clap.
:::static
import afield.simulation.petri.*
val net =
petri(
"""
#petri
!grid 1/8
// four places round: a kick each lap, and a token into charge
k0 : 1
k0 -[ kick : bd ]-> k1 + charge
k1 -[ k1.go ]-> k2
k2 -[ k2.go ]-> k3
k3 -[ k3.go ]-> k0
// three places round: a snare each lap
s0 : 1
s0 -[ snare : sn ]-> s1
s1 -[ s1.go ]-> s2
s2 -[ s2.go ]-> s0
// five places round: two hats each lap
h0 : 1
h0 -[ hat : hh ]-> h1
h1 -[ h1.go ]-> h2
h2 -[ hat.2 : hh ]-> h3
h3 -[ h3.go ]-> h4
h4 -[ h4.go ]-> h0
// four kicks fill charge, and emptying it is a clap
charge : 0 ^4
4*charge -[ clap : cp ]->
""",
)
:::fold draw it, and find the first clap
val run = net.run()
val firstClap = run.firingsOver(afield.audio.synth.PatArc(cyc(0), cyc(4))).first { it.transition == "clap" }
showPetriNet(net, w = 900.0, h = 560.0)
markdown(
"main",
"""
#### three rings and a counter
- transitions that make a sound: ${net.transitions.filter { it.emit != null }.map { "`${it.name}` → `${it.emit}`" }.joinToString(", ")}
- the first clap fires at cycle **${firstClap.at}**, one step after the fourth kick
""",
)
displayOpen("main")
:::endfold

The text after each colon names what a transition emits. These are Dirt-Samples folder names,
so net.emits() gives us a drum pattern.
What it sounds like
Eight cycles at half a cycle a second, played by patRenderStereoDirt from the pinned
Dirt-Samples:
To play it live from a box instead, hand the same pattern to a Dirt player:
dirt("/path/to/Dirt-Samples")
net.emits().playDirt(dirtPlayer(), cps = 0.5)
From firings to a buffer
The net box publishes the net and one run of it, prop<PetriRun>(sparkRun), and the sparks box
below it reads that run once a frame. A run remembers the steps it has already taken, so asking it
about the last second and a half each frame is cheap. Three questions get the whole picture:
// every firing in the last `life` seconds: one burst each
val recent = sparkRun.firingsOver(PatArc(since, at)).filter { it.transition in launches }
// which place of each ring holds the token: where the comets are
val marking = sparkRun.markingAt(at)
// how full charge is, 0 to 4: the dust brightens as it fills
charge = sparkRun.countAt("charge", at) / 4.0
Each recent firing becomes one vec4 in a small buffer made with perFrame = true, so it can be
rewritten every frame without waiting for the GPU. The four numbers are where the burst started,
how old it is in seconds, and which transition fired, with a per-firing seed in the fraction so no
two bursts look alike:
val (colour, ring, place) = launches.getValue(firing.transition)
val age = (now - firing.at.toDouble()) / cps
put(vec(r * Math.cos(a), r * Math.sin(a), age, colour + (firing.index % 4093) / 4096.0))
firing.at is the instant in cycles that the transition fired, the same instant the drum is struck
in the audio above.
In the vertex shader
There is no compute pass and no particle state on the GPU: every spark's position is a function of its burst and its age, so the vertex shader works it out from scratch each frame. A spark leaves at a random angle and speed, slows under drag and falls under gravity, and is drawn as a short streak along its velocity:
centre = burst.xy + v * (1.0 - exp(-drag * age)) / drag + 0.5 * gravity * age * age;
vec2 velocity = v * exp(-drag * age) + gravity * age;
along = normalize(velocity + vec2(1e-5));
halfLength = 0.003 + 0.045 * length(velocity);
The module blends with srcColor and dstColor both set to VK_BLEND_FACTOR_ONE, so overlapping
sparks add up and the centre of a fresh burst burns white.
Why the figure is a still, and silent
The docs runner records one frame of the native window, not a clip. The sparks box counts frames at sixty a second as its clock rather than reading the audio clock, so the figure is the same on a machine with no sound output.
Where each piece is documented
- Petri nets for the notation,
emits(), runs and markings. - A pattern that moves particles for the other way of tying sound to particles, by measuring the audio rather than reading the score.
- Field 4 Graphics for modules, meshes, buffers and push constants.
- Making a sound, every path there is for the Dirt-Samples and the other ways to play a pattern.