Patterns
Musical time, as a value. A PatPattern<A> is a function from a span of cycles to the events inside it, so it is queried rather than stored, and a pattern that never repeats costs no more to hold than one that does. Time is kept as an exact rational (PatQ, BigInteger over BigInteger) all the way through the transforms and is only converted to seconds at the boundary where something has to be scheduled, which is why a third of a cycle survives fast, rev and every without drifting. The notation and much of the vocabulary is Tidal's — pat("bd [hh hh] sd"), stack, jux, off, degradeBy, the |+| operator family — but an atom here means whatever you want it to mean, not a sample name, which is what lets the same pattern drive a note process, a Field Fn, a set of signal-graph parameters or an arbitrary side effect. Sequencing.kt is the note-and-piano-roll layer patterns bridge into, and BeatLaunch.kt is .onBeat { … }, which holds any launchable value until the next musical boundary. Patterns: writing rhythm as a function of time is the prose for all of it.
40 source file(s), 1317 public symbol(s), 721 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.
BeatLaunch.kt
Quantised launching: .onBeat(…) holds any launchable value until the next boundary of a
PatClock's grid, and waitForBeat(…) is the same wait spelled as a suspend call for code
inside frames { } or loop { }.
A grid here is two numbers against a clock. every is the spacing, in that clock's units, and
offset slides the whole grid, so every = 4, offset = 1 puts boundaries at 1, 5, 9 and so on.
preroll moves only the wake-up, not the boundary: waking a little early is how work that takes
time can still land on the beat, and the value reported back is always the boundary itself.
.onBeat is an extension on T, not on any launch type, so it wraps whatever it is given,
including a bundle built with +. It returns a BeatLaunchSpec, which does nothing on its own.
The handler registered at the bottom of this file recognises that spec when it arrives as a
box's last expression, waits, and then hands the value it was wrapping back to the normal launch
machinery. A bundle wrapped once therefore starts as a unit:
(audio { saw * 0.05f } +
pat("bd [hh hh] sd").respond(cps = 0.5) { event ->
__m("pat").patHighlight(event)
}).onBeat(every = 4, preroll = 0.02)
Because the wait runs as a job owned by the box, stopping the box before the boundary arrives cancels the launch rather than deferring it.
A boundary the clock is already sitting on, within 1e-6 of a clock unit, counts as reached, so
.onBeat on the downbeat starts now rather than a full period later.
src/jvmMain/kotlin/afield/audio/synth/BeatLaunch.kt — read the source
Top-level functions
BeatLaunchSpec
A launchable value with a grid attached, waiting to be returned from a box.
| Declaration | Summary |
|---|---|
override fun toString(): String |
BooleanNetworkSequencing.kt
Gives a BooleanNetwork's states a duration, which is all it takes to read one as music.
BooleanNetwork itself is untimed: it is a list of nodes whose update rules all fire at once,
and step takes one state vector to the next. Everything here is an extension on that class, so
the dependency runs one way only. Nothing in the network model knows about clocks, notes or
boxes, and the network can be built, explored and drawn without any of this.
A timed run is a BooleanNetworkStep per update: the state, the state before it, and a start
and duration in seconds. One node is one lane, indexed as the network indexes its nodes, and
BooleanTriggerMode decides what counts as an event on a lane. Choosing the mode does most of
the musical work, because a node that stays on for six steps is six notes under ACTIVE and one
under RISING_EDGE.
There are three ways out, in increasing order of commitment:
scheduledStepsandscheduledDrivenStepshand back the raw timed states as anIterator, for anything that wants to read the rhythm itself.scheduledNotesandasNoteProcesstake a mapper from a step toScheduledNotes and give back an iterator or aNoteProcess, which is the same thing a hand-written sequence produces, sonotesBetweenandpianoRollSvgwork on it unchanged.respondandrespondAheadbuild aBooleanNetworkResponseSpec, which runs the network against aPatClockin real time and calls back per step. Returning one from a box is what starts it.
The Driven half of each pair takes a BooleanNetworkStateDriver, a lambda that writes into the
state vector before the network reads it. The network then updates from the forced value as
though it had produced it, which is how an oscillator, a MIDI input or a second network gets to
be one of the lanes.
Leaving steps null makes every one of these iterators infinite. That is the right answer for a
responder and the wrong one for anything that collects, so pass a bound when you are building a
list.
src/jvmMain/kotlin/afield/audio/synth/BooleanNetworkSequencing.kt — read the source
Used in: Boolean network music examples
Top-level functions
BooleanTriggerMode
What counts as a lane firing on a given step, given the lane's state now and on the step before.
Constants: ACTIVE, RISING_EDGE, FALLING_EDGE, CHANGE.
BooleanNetworkStateDriver
Mutates the Boolean state that will be emitted for a scheduled step before the network advances. Useful for forcing clock lanes or other external inputs.
BooleanNetworkStep
One update of a Boolean network, placed in time: the state it emitted, the state before it, and the seconds it occupies.
| Declaration | Summary |
|---|---|
val stepIndex: Long |
|
val start: Double |
|
val duration: Double |
|
val state: BooleanArray |
|
val previousState: BooleanArray? |
|
val end: Double |
When the next step begins. Steps are contiguous, so this is also the following start. |
fun isActive(lane: Int): Boolean |
Whether lane is on now. A lane outside the network's range is off rather than an error. |
fun wasActive(lane: Int): Boolean |
Whether lane was on one step ago. False on the first step, which has nothing to compare. |
fun fires(lane: Int, mode: BooleanTriggerMode = BooleanTriggerMode.ACTIVE): Boolean |
Whether lane counts as an event under mode. See BooleanTriggerMode for the four. |
fun lanes(mode: BooleanTriggerMode = BooleanTriggerMode.ACTIVE): List<Int> |
Every lane firing under mode, in ascending lane order. The usual body of a step mapper: |
override fun toString(): String |
BooleanNetworkResponseScope
The receiver a respond callback runs against: the box it belongs to, the network and settings it was launched with, and now.
| Declaration | Summary |
|---|---|
val box: Box |
|
val network: BooleanNetwork |
|
val initialState: BooleanArray |
|
val stepDuration: Double |
|
val clock: PatClock? |
|
val ahead: Double |
|
val launchInstant: Instant |
|
val launchElapsed: Double |
Seconds of wall time since the responder started, whether or not there is a clock. |
val now: Double |
The timeline this responder is scheduling against: clock's reading, or launchElapsed. |
override fun toString(): String |
BooleanNetworkResponseSpec
A clocked Boolean-network run, built and not yet started.
| Declaration | Summary |
|---|---|
override fun toString(): String |
CaSoundDocs.kt
A cellular automaton as a rhythm, by way of the pattern language's own inverse parser.
The route is four steps and each one belongs to a different part of Field: an elementary rule is
stepped by alife_ca.kt, one of its rows becomes a step mask, patCondense finds the shortest
mini-notation that plays that mask, and patRenderStereoDirt plays the notation from the pinned
Dirt-Samples subset.
So the sound is not "a sonification of a CA" in the loose sense. It is the CA's row, spelled as
something a musician would have typed, and then played.
The condenser earns its place in the middle of that: it is a measurement. A row with
structure gets a much shorter spelling than one without, so CaBar.compression separates rule
90's self-similar rows from rule 30's centre column. Neither ratio can exceed one, because the
search is never allowed to lose to the flat spelling and a flat spelling carries overhead that
any grouping saves, so the rule30-hats scene asserts the ratio between the two -- rule 30's
at least 1.4 times rule 90's -- rather than a floor on either. That gap is the difference between
the two scenes here, and you can hear it.
Every scene has an oracle outside the CA
Rule 90 from a single seed is Pascal's triangle mod 2, so binomialParity computes what the
stepper should have produced without stepping anything, and gasketBar checks the two against
each other before any sound is made. Row n's population is exactly 2^popcount(n), which is
what gasketScale turns into pitch, so the melody's note sequence is checkable digit by digit.
rule30Bar has no such closed form, so what is asserted there is the negative: a population near
half, and a longest run short enough that the sequence has no long runs in it.
What it does not do
No realtime engine, no audio device, no GPU, no Field window. The renders are deterministic, so a
committed .wav regenerates byte-stably, which is the contract DOUGH_DSP_PLOT_GALLERY.md sets
and PatSoundDocs follows. Run as the synth.ca.sound FIELD_TEST, every scene renders to a
temporary directory and runs its own checks, and nothing committed is written. Pass an output
directory as args[0] to regenerate the page's .wavs.
src/jvmMain/kotlin/afield/audio/synth/CaSoundDocs.kt — read the source
CaSoundDocs
The three cellular-automaton scenes, their oracles, and the renderer that writes them out.
| Declaration | Summary |
|---|---|
const val SR: Int |
Shared with PatSoundDocs, because the two galleries write the same kind of file. |
class CaBar(val label: String, val mask: List<Boolean>, val on: String) |
One bar derived from a cellular automaton: where it came from, the steps it fires on, and the shortest mini-notation that plays it. |
fun gasketBar(generation: Int = 20, on: String = "x"): CaBar |
Row generation of rule 90 from a single seed, read along the sublattice the seed can reach. |
fun rule30Bar(generations: Int = 32, on: String = "x"): CaBar |
The centre column of rule 30's space-time diagram, generations long, as a step mask. |
fun gasketScale(generations: Int = 32, root: Int = 45, scale: List<Int> = listOf(0, 3, 5, 7, 10, 12)): List<Int> |
The population of each of rule 90's first generations rows, as pitch. |
fun gasketPopulations(generations: Int = 32): List<Int> |
How many cells are alive in each of rule 90's first generations rows, counted off the real space-time diagram. |
class Scene(val name: String, val title: String, val blurb: String, val cycles: Int, val cps: Double, val render: (Int, Double) -> PatSoundDocs.Stereo, val verify: Scene.(PatSoundDocs.Stereo) -> Unit = {}) |
Shares PatSoundDocs.Scene's shape so the two galleries' harnesses stay comparable. |
val SCENES: List<Scene> |
|
fun playDirt(pattern: PatPattern<String>, token: String, cycles: Int, cps: Double, speed: (String) -> Double = { 1.0 }): PatSoundDocs.Stereo |
Plays every onset of pattern as the Dirt sample token, strictly from the pinned subset. |
fun renderWav(name: String, outPath: String) |
Offline-render scene name and write a stereo 16-bit PCM WAV to outPath. |
fun main(args: Array<String>) |
|
val CONDENSATION_SURVEY: List<Int> |
The rules the survey covers. 90 and 150 are the additive ones, 30 and 45 the two usually called chaotic, 110 the universal one, and 250 is here because its centre column is periodic with period two from a single seed, which is the shortest interesting answer there is. |
fun centreColumnBar(rule: Int, generations: Int = 32, on: String = "x"): CaBar |
The centre column of rule over generations steps, as a bar. Used by the survey and the docs page. |
DrawnMorphDocs.kt
The harness that renders the sound on chain_drawn_morph.md from two strokes in a document's
drawing.
DrawnMorphDocs reads a circle and a heart back through notation, pairs their points and plays
the in-between cloud's size as pitch. It runs as the synth.drawn.morph FIELD_TEST.
src/jvmMain/kotlin/afield/audio/synth/DrawnMorphDocs.kt — read the source
Used in: A Drawn Morph, Heard
Top-level functions
| Declaration | Summary |
|---|---|
fun morphBetween(from: List<Vec2>, to: List<Vec2>, t: Double): List<Vec2> |
The in-between cloud: point i a fraction t of the way from from[i](workspace.md#stdlib-kt--i) to to[i](workspace.md#stdlib-kt--i). |
fun spreadOf(points: List<Vec2>): Double |
How big a cloud is: the root-mean-square distance of its points from their centroid. |
fun NotationBuilder.morphShapes() |
The two strokes chain_drawn_morph.md reads: a circle named "from" and a heart named "to". Call it inside notation { }. |
DrawnMorphDocs
A drawn morph, heard — the harness behind public/docs/chain_drawn_morph.md.
| Declaration | Summary |
|---|---|
const val SR: Int |
|
const val N: Int |
Points per shape after resampling. |
const val CX: Double |
Where both shapes are centred on the canvas, and their size, in world units. |
const val CY: Double |
|
const val R: Double |
|
const val BEATS: Double |
One cycle is eight beats; at a quarter cycle a second the morph out and back takes four seconds. |
const val CPS: Double |
|
const val CYCLES: Int |
|
val VOICE: PatVoiceSpec |
|
fun circle(): List<Vec2> |
The circle, as a hand would draw it: from the right, anticlockwise on screen, bunched at the start. |
fun heart(): List<Vec2> |
The heart, from its bottom point, clockwise on screen (y grows down). |
fun shapes(box: Box): Pair<List<Vec2>, List<Vec2>> |
The resampled pair the page's boxes read back from the drawing. |
fun morphNotes(from: List<Vec2>, partner: List<Vec2>): List<TimedNote> |
The morph played as pitch, word for word the loop on the page (DrawnMorphPageTest compares them): 32 steps out to partner and back, each step a quarter beat at a pitch that is MIDI 72 when the cloud is the circle's size and falls an octave for every halving of it. |
fun render(notes: List<TimedNote>): PatSoundDocs.Stereo |
|
fun <T> headless(block: (Box) -> T): T |
Runs block with a box in an ephemeral document whose drawing holds morphShapes. |
fun sceneNotes(name: String): List<TimedNote> |
The notes each published clip plays. |
fun renderWav(name: String, outPath: String) |
|
fun main(args: Array<String>) |
DrawnScoreDocs.kt
A drawn score, heard — the harness behind public/docs/chain_drawn_score.md.
The route is three parts of Field that were written without each other in mind. A box draws a
score into the document's drawing with notation { drawnScore() }: a frame, a piano roll of
rectangles, a glissando drawn as a pen stroke, a held note drawn as a level line, and a text
label. notation.space("score", x = 0.0..8.0, y = 84.0..48.0) reads that drawing back as eight
beats of MIDI pitch, interpret decides what each event means as notes (the loop the page
shows), patTimed makes those a PatPattern, and patRenderStereo plays the pattern offline
into a .wav. pentatonic is the page's second reading of the same drawing.
Then the sound is taken back apart. listen finds the onsets in the rendered audio with
PatSoundDocs.onsetPeaks and the pitch of each note with YIN (patF0Frames), and holds both
against the notes read from the drawing — so the claim "the audio plays the score" is a
measurement with an error in milliseconds and in semitones, not a caption.
renderWav needs no Field window and no browser: it opens an ephemeral document of its own,
draws the score into its drawing with the same notation { } code a box uses, and reads it
back through the same NotationSpace. As the synth.drawn.score FIELD_TEST everything renders
to a temporary directory and nothing committed is written.
src/jvmMain/kotlin/afield/audio/synth/DrawnScoreDocs.kt — read the source
Used in: A Drawn Score, Heard
Top-level functions
| Declaration | Summary |
|---|---|
fun NotationBuilder.drawnScore() |
Draws the score DrawnScoreDocs and chain_drawn_score.md read: a frame named "score", a piano roll of rectangles, a glissando drawn as a pen stroke, a held note drawn as a level line with a hand's wobble in it, and a text label. Call it inside notation { }. |
DrawnScoreDocs
Renders and measures the drawn-score page's audio; see the file overview.
| Declaration | Summary |
|---|---|
const val SR: Int |
|
const val LEFT: Double |
The score's frame on the canvas, in world units, and what its axes mean. |
const val TOP: Double |
|
const val WIDTH: Double |
|
const val HEIGHT: Double |
|
const val BEATS: Double |
|
const val HIGH: Double |
|
const val LOW: Double |
|
const val CPS: Double |
One cycle is the whole score, so 0.25 cycles a second is eight beats in four seconds. |
const val CYCLES: Int |
|
val VOICE: PatVoiceSpec |
The voice. Detached on purpose: each note holds for 40% of its length and its tail is gone 16 ms later, so even the glissando's 62 ms steps are separated by true silence. With a longer tail PatSoundDocs.onsetPeaks loses steps — it re-arms against the level of the window that triggered it, which on a steep attack is a point on the rising edge rather than the note's peak, so a dip to a few percent between steps is not always deep enough. |
const val MIN_HZ: Double |
The register YIN is told about. The score spans MIDI 60..79, about 262..784 Hz. |
const val MAX_HZ: Double |
|
fun at(beat: Double, midi: Double): Vec2 |
Where beat beat and pitch midi are on the canvas. |
val ROLL: List<Triple<Double, Double, Double>> |
The piano-roll notes as drawn: start beat, end beat, MIDI pitch. |
const val GLISS_FROM: Double |
The glissando's ends: beat 4 at MIDI 60 up to beat 6 at MIDI 79, eased in and out. |
const val GLISS_TO: Double |
|
const val HELD: Double |
The held note: beats 6..7.5 at MIDI 76, drawn with a wobble a hand would give it. |
fun space(box: Box): NotationSpace |
A space over the score frame, with the axes the page uses. |
fun interpret(score: NotationSpace, glideStep: Double = 0.125): List<TimedNote> |
The score's events as notes: the loop chain_drawn_score.md shows in its box, word for word between val notes and the loop's closing brace — DrawnScorePageTest compares the two, so the published .wav is made by the code the page teaches. A rectangle or a level stroke is one note at its pitch; a moving stroke is cut with vertical every glideStep beats, with pen pressure as gain; texts, frames and arrows are skipped. |
fun pentatonic(notes: List<TimedNote>): List<TimedNote> |
The page's second reading: every note snapped to C minor pentatonic, and a step that lands on the pitch the previous one did held rather than struck again. Word for word the page's loop. |
val MINOR_PENTATONIC: Set<Int> |
The pitch classes pentatonic snaps to. |
fun render(notes: List<TimedNote>, cycles: Int = CYCLES, cps: Double = CPS): PatSoundDocs.Stereo |
notes played through VOICE, as a BEATS-beat cycle. |
fun <T> headless(block: (Box) -> T): T |
Runs block with a box in an ephemeral document of its own, so a drawing can be written and read with no Field window. The thread's current document is restored afterwards. |
fun sceneNotes(name: String): List<TimedNote> |
The notes each published clip plays, read from a headless drawing. |
fun renderWav(name: String, outPath: String) |
A published clip: the score drawn headlessly, read back with interpret, and rendered. |
class Heard(val note: TimedNote, val scheduledSeconds: Double, val onsetSeconds: Double, val heardMidi: Double) |
One note of the score against what the audio did there. |
class Listening(val heard: List<Heard>, val onsetsDetected: Int, val frames: List<PatF0Frame>) |
What listen measured: one Heard per scheduled note, plus the detector's own totals. |
fun listen(audio: PatSoundDocs.Stereo, notes: List<TimedNote>, cycles: Int = CYCLES, cps: Double = CPS, sustain: Double = VOICE.sustain): Listening |
Measures audio against notes: an onset for every strike, and a pitch for every note. notes are in beats of a BEATS-beat cycle played at cps for cycles cycles, by a voice that holds each note for sustain of its length. |
fun main(args: Array<String>) |
HeadTrackedSpatial.kt
The seam between a tracked head and patSpatial's listener.
The two halves of head-tracked binaural audio meet here and nowhere else:
OpenXRHmdTarget.headPose reports where the wearer's head is, and PatSpatialRoom renders a
pattern through Steam Audio's HRTF from wherever it is told the listener is. Between them sits
HeadPoseSource, an interface narrow enough that the audio side cannot tell an HMD from a
script.
The conversion is a copy
OpenXR and Steam Audio use the same frame: right-handed, +X right, +Y up, -Z ahead, metres.
So HeadPose.ofOpenXR copies seven numbers and converts nothing. A handedness error between
these two frames leaves level, distance, occlusion and reverb all correct and silently swaps the
listener's ears. That is the failure afield.audio.spatial.steamaudio.World2D and
World2DHandednessTest guard against on the geometry side; HeadTrackedSpatialSmokeTest guards
this side of it, on rendered audio rather than on the paragraph above.
Where to pump from
OpenXRHmdTarget's pose comes from xrLocateViews on the renderer's queue thread and is
published whole, once per frame, so the timer default is safe for it as for a StereoPoseSource
(which latches by contract). Construct with hz = 0.0 and call HeadTrackedListener.pump from
the frame callback only when the pose should change exactly on frame boundaries.
src/jvmMain/kotlin/afield/audio/synth/HeadTrackedSpatial.kt — read the source
Used in: A pattern in space: pat → Dough → Steam Audio
Top-level functions
| Declaration | Summary |
|---|---|
fun OpenXRHmdTarget.asHeadPoseSource(): HeadPoseSource |
This headset's pose, as something PatSpatialRoom can follow. |
fun StereoPoseSource.asHeadPoseSource(): HeadPoseSource |
This pose source's latched pose. Call StereoPoseSource.latch on the frame, as usual. |
HeadPoseSource
Where a head is, in the room's frame, asked once per poll.
| Declaration | Summary |
|---|---|
fun headPose(outPosition: Vec3, outOrientation: Quat): Boolean |
Fill outPosition and outOrientation with the head's current pose, in the room's frame. |
VirtualHead
A head that moves the way a function says, for when there is no runtime to ask.
| Declaration | Summary |
|---|---|
val clock: (() -> Double)? |
|
val pose: (seconds: Double) -> HeadPose |
|
var tracking: Boolean |
Whether this head reports tracking. Set false to exercise a consumer's dropout path -- which is a real path, not a hypothetical one: an OpenXR runtime returns an invalid view state whenever the headset is off the wearer's face. |
fun start() |
Restart the wall clock, so the next headPose is the pose at zero. No-op with a clock. |
val seconds: Double |
Seconds on this head's clock, or since start when it has none. |
override fun headPose(outPosition: Vec3, outOrientation: Quat): Boolean |
HeadTrackedListener
Follows a HeadPoseSource and keeps a PatSpatialRoom's listener where the head is.
| Declaration | Summary |
|---|---|
val room: PatSpatialRoom |
|
val head: HeadPoseSource |
|
val hz: Double |
|
var updates: Long |
Poses read and handed to the room. |
var dropouts: Long |
Polls where the head reported no usable pose and the last one was held. |
var lastPose: HeadPose? |
The last pose handed to the room, or null before the first one. |
fun pump(): Boolean |
Read the head once and hand the room the result. Returns false on a dropout. |
var failure: Throwable? |
What the pose source threw, if it threw. Polling continues; a tracker is not worth a crash. |
fun start() |
Start the timer thread. A no-op when hz is 0, since there is nothing to start. |
val isRunning: Boolean |
|
override fun close() |
Stop following. The room keeps the last pose it was given. |
override fun toString(): String |
HeadTrackedSpatialSmokeTest.kt
The listener half of patSpatial's audio check: the pattern holds still and the head moves.
PatSpatialSmokeTest varies the tag and leaves the listener at the origin. Here the tag is fixed
and a head pose of the shape OpenXR reports drives the HRTF, over six cases: an identity pose
against no pose at all, a yaw, a step sideways, a walk towards the source, a tracking dropout,
and a VirtualHead on its own thread writing poses while the render thread reads them.
Nothing here is asserted against the wall clock. PatSpatialRoom's render loop has no pacing
beyond sleeping when its ring is at target fill, so a capture that drains the ring as fast as
blocks appear lets the whole chain free-run at whatever rate the machine allows. Every case
prints the factor it reached and none of them depends on it; PatSpatialBudgetProbe owns the
timing questions.
Run it with tools/run_field_tests.py --id synth.head-tracked-spatial.smoke.
src/jvmMain/kotlin/afield/audio/synth/HeadTrackedSpatialSmokeTest.kt — read the source
HeadTrackedSpatialSmokeTest
object HeadTrackedSpatialSmokeTest
Proves that a head pose of the shape OpenXR reports aims Steam Audio's HRTF, on real audio.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
LambdaSoundDocs.kt
A λ-term's reduction as a rhythm — the schedule, not the answer.
The route is four steps in four parts of Field that do not know about each other.
afield.lang.lambda.lambdaterms parses and compiles a term to an interaction net,
afield.data.graph.interactionnets reduces that net one reaction at a time, this file records each
reaction as a Beat, and patCondense, the mini-notation parser run backwards, finds the
shortest notation that plays the resulting step mask. patRenderStereo plays it.
What this measures
stdlib.lambda-terms.property established on 2026-09-19 that the answer does not depend on
the scheduler, over 17 200 (term, scheduler) checks. It says nothing about the route taken to
reach it, which is what a trace records. So the measurement is the pair:
- the normal form, which is the same under every
ActivationOrderthe harness runs, and - the step count, the rule sequence, the β mask and its condensation, which are not.
patCondense earns its place for the same reason it does in CaSoundDocs: it is a
measurement. A schedule whose β-steps fall into a figure gets a short spelling; one that
scatters them gets a long one. Trace.betaCompression is therefore a number about a scheduler
rather than about a term.
The oracles
Every trace is checked against something outside the trace before it is allowed to make a sound:
Trace.resultis asserted α-equivalent toLambdaTerms.referenceNormalOrder, the plain capture-avoiding evaluator, and equal across every order inORDERS.- every
Beatcarries at least one event, and every event kind is one of the sixLambda.rulesdeclares — soEVENT_KINDSis checked against the rule book rather than trusted. - the β count is asserted constant across schedulers while the total is not: see
betaIsScheduleInvariant.
What it does not do
No realtime engine, no audio device, no GPU, no Field window, and no layout:
relaxIterations = 0 throughout, because the force-directed layout is O(n²) per iteration and
a reduction nobody is watching should not pay for a picture. The renders are deterministic, so a
committed .wav regenerates byte-stably. As the synth.lambda.sound FIELD_TEST every scene
renders to a temp dir and nothing committed is written; pass an output directory as args[0] to
regenerate the page's .wavs.
src/jvmMain/kotlin/afield/audio/synth/LambdaSoundDocs.kt — read the source
LambdaSoundDocs
Reduces each term in TERMS under every order in ORDERS, checks the traces against the oracles, and renders them as .wav.
| Declaration | Summary |
|---|---|
const val SR: Int |
Shared with PatSoundDocs, because every docs gallery writes the same kind of file. |
val ORDERS: List<ActivationOrder> |
The schedulers this page compares — the same four as stdlib.lambda-terms.smoke. |
val EVENT_KINDS: List<String> |
The whole event vocabulary Lambda.rules declares. Every kind a reduction emits must be one of these; assertTheVocabularyIsWhatItSays checks it. |
val SOUNDING_KINDS: List<String> |
The kinds a closed term emits, in the order this file maps to pitch. |
val KIND_NOTES: List<Int> |
MIDI note per SOUNDING_KINDS index: degrees of a minor pentatonic off A2. scope and unscope (Lambdascope's delimiters moving and closing) sit above the other four. |
fun noteOf(kind: String): Int |
The MIDI note a rewrite of this kind plays, or the root for a kind that does not sound. |
val TERMS: List<String> |
The named terms this page reduces, as source text, taken from LambdaTerms.corpus by name so that the page and the front end's own corpus cannot drift apart. |
fun sourceOf(name: String): String |
The corpus entry name resolves to. |
class Beat(val index: Int, val rule: String, val kinds: List<String>, val nodes: Int, val activePairs: Int, val reduciblePairs: Int) |
One reaction, as the recorder saw it — the unit of the time series this page plots and plays. |
class Trace(val name: String, val source: String, val order: ActivationOrder, val term: Term, val result: Term, val beats: List<Beat>, val fans: Int, val initialNodes: Int, val net: Net, val budget: Int) |
A whole reduction, recorded: the term, the schedule, every Beat, and the answer read back. |
fun trace(name: String, order: ActivationOrder = ActivationOrder.FIRST_FOUND, maxSteps: Int = 20_000): Trace |
Compile source, reduce it under order, and record every reaction. |
fun traceSource(name: String, source: String, order: ActivationOrder = ActivationOrder.FIRST_FOUND, maxSteps: Int = 20_000): Trace |
trace, on source text that need not be in LambdaTerms.corpus — what CONFLUENCE_PROBES uses. |
fun traceAllOrders(name: String): Map<ActivationOrder, Trace> |
trace under every entry of ORDERS, keyed by order. |
fun answerIsScheduleInvariant(traces: Map<ActivationOrder, Trace>): String |
The answer is the same under every scheduler, and it is the one a plain evaluator gets. |
fun betaIsScheduleInvariant(traces: Map<ActivationOrder, Trace>): Int |
The β count is the same under every scheduler even though the total reaction count is not. |
fun assertTheVocabularyIsWhatItSays(traces: List<Trace>): Set<String> |
EVENT_KINDS is the rule book's vocabulary, checked against the rule book. |
fun corpusSweep(): Map<String, Map<ActivationOrder, Trace>> |
Every corpus term under every scheduler, with the reaction count and the rule multiset that came out — the sweep behind this page's central claim. |
fun stepCountIsScheduleInvariant(sweep: Map<String, Map<ActivationOrder, Trace>>): Map<String, Int> |
The reaction count is the same under every scheduler, for every term in the corpus. |
fun ruleMultisetIsScheduleInvariant(sweep: Map<String, Map<ActivationOrder, Trace>>): Int |
Stronger than stepCountIsScheduleInvariant, and also true: the multiset of rules is the same under every scheduler, not merely its size. |
fun spellingsPerTerm(sweep: Map<String, Map<ActivationOrder, Trace>>): Map<String, Int> |
The terms whose β mask is spelled differently by different schedulers, and how many distinct spellings each has. |
val CONFLUENCE_PROBES: Map<String, String> |
The four terms stdlib.lambda-terms.property pins by name, verbatim. |
val DISAGREEING_PROBES: Set<String> |
Which of CONFLUENCE_PROBES disagree with the normal-order evaluator: none. |
fun assertTheProbesAreStillCounterexamples(maxSteps: Int = 4_000) |
Exactly DISAGREEING_PROBES of CONFLUENCE_PROBES disagree with the normal-order evaluator. |
fun probeStepCounts(maxSteps: Int = 4_000): Map<String, List<Int>> |
The probes' reaction counts under every scheduler — the harder half of the invariance claim. |
class Scene(val name: String, val title: String, val blurb: String, val cycles: Int, val cps: Double, val render: (Int, Double) -> PatSoundDocs.Stereo, val verify: Scene.(PatSoundDocs.Stereo) -> Unit = {}) |
Shares CaSoundDocs.Scene's shape, so the two galleries' harnesses stay comparable. |
val SCENES: List<Scene> |
|
const val MELODY_TERM: String |
The corpus term whose rule sequence becomes the melody. |
const val BAR_TERM: String |
The corpus term both beta bars reduce. Its beta mask is spelled four different ways by the four orders. |
fun renderWav(name: String, outPath: String) |
Offline-render scene name and write a stereo 16-bit PCM WAV to outPath. |
fun main(args: Array<String>) |
Marf.kt
Function generation over the pattern language: stored material, a trajectory that reads it, and the timing of that traversal, kept as three separate patterns.
The idea is borrowed from the Buchla 248 Multiple Arbitrary Function Generator, and the part worth borrowing is not its step sequencer. It is the separation the 248 makes between
- the material stored in a bank of stages,
- the address that says which stage to read next,
- the time each stage is allowed to take,
- and the continuous function the generator draws while a stage is active.
On the hardware those are four patch points. Here they are four ordinary PatPatterns, which is
the whole point: slow, rev, every, euclid, degradeBy, randcat and everything else in
TidalPatterns.kt / PatComposition.kt already work on all four, so this file adds almost no
combinators of its own. A reader is built from patterns and is a pattern: signal() hands back
a PatPattern<Double> in the sense of PatSignals.kt, and MarfReader.triggers hands back a
discrete PatPattern<String>. Both ends plug straight into with(gain(...)), struct,
segment, respond and the scheduler.
Read the surface from the outside in:
-
The four fields, patterned independently. Nothing here is a grid; the durations are real:
val env = marf( value = pat("<0 .7 .2 1>").nums(), duration = pat("<1 4 .25 2>").nums(), curve = curves("
"), fields = MarfFieldClock.STAGES ) env.at(3.5).value // the continuous output, 3.5 cycles in env.at(3.5).stagePhase // where inside its own stage that was
-
Stored material, read by a trajectory. Two readers, one bank, different journeys:
val b = marfBank( rampTo(0.0), rampTo(0.3), holdAt(0.2), oscTo(0.9, cycles = 3), rampTo(0.8) )
val a = b.read(pat("0 1 2 1 3 4").nums()) val c = b.read(pat("0 2 4 1 3").nums().slow(3)) val d = b.read(struct(euclid(5, 8), pat("0 4 2 3").nums()))
The address is a pattern, so the trajectory is compositional material rather than a mode
flag. "<0 1 2 3 2 1>" is ping-pong. .every(4) { it.rev() } is periodic reversal. There is
no direction = PINGPONG in this file and there should never be one.
-
Out, as a signal or as pulses — the same temporal structure, both ways:
pat("bd*8") with gain(a.signal().range(0.2, 1.0)) // continuous, into the algebra a.triggers().respond(cps = 1.0) { spawnParticles() } // discrete, one per stage
-
And the missing conversion, which is useful with or without a bank — an event stream read as a continuous function:
pat("0 .5 1 .25").nums().hold() // sample and hold pat("0 .5 1 .25").nums().ramp() // linear between onsets pat("0 .5 1 .25").nums().interp(MarfCurve.Smooth)
Where a stage's numbers come from
Stage k begins at an exact cycle instant Tₖ, and every field is sampled at Tₖ — value,
duration, curve, trigger, address, speed — through patSampleOver, the same latch the parameter
algebra uses for gain(...) at a note onset. Then Tₖ₊₁ = Tₖ + durationₖ.
A field pattern with rests holds rather than resets. If nothing is sounding at Tₖ — a
struct(euclid(5, 8), …) address on an off step, a degradeBy that dropped this one — the field
keeps the value the previous stage had. That is what a sample-and-hold does, and it is what makes
the thinning operators mean something on an address: struct and degradeBy become how long an
address is held for, rather than "and now, silently, address zero".
That recurrence is why this is not a sequencer: the reader's own traversal generates its clock,
so the duration pattern is consulted at instants the duration pattern chose. Give four values a
"<1 4 .25 2>" duration and you get
|--0--|--------.7--------|-.2-|----1----|
rather than four equal steps. Give the address a period of 6 and the duration a period of 4 and the two drift against each other with a period of 12 — which is the whole reason to keep them apart.
There is one real choice buried in "sampled at Tₖ", and MarfFieldClock is where it lives.
Reading the fields at the wall-clock instant Tₖ (CYCLES) is the hardware answer
and never fails silently: every pattern says something at every instant. Reading them at cycle
k instead (STAGES) makes the fields a per-stage sequence, so duration = "<1 4 .25 2>"
gives stages of exactly those four lengths and address = "<0 1 2 1 3 4>" walks the bank one
slot per stage no matter how long a stage takes. In STAGES one cycle of a field pattern is one
stage, so a bare "0 .7 .2 1" has three steps the clock cannot see — and rather than collapse to
0, that is now refused at construction, by field name. See marfCheckFieldClock.
STEPS is the third reading and the one that means what a sub-cycle field looks like it means:
it subdivides each cycle of STAGES, so a field contributes one stage per onset and at least one
per cycle. address = "<0 3> 1" is then the three-entry sequence 0, 1, 3, 1 rather than an
error. STEPS and STAGES agree exactly on every pattern with at most one onset per cycle, so
<…>, slow(3) and a signal read identically in both.
Only a stack is beyond all three. "<0 3>, 1" puts two layers on one instant, a field latches to
one value, and no choice of clock recovers the other — so that too is refused in the sequence
readings rather than half-read.
Accumulation is exact. Durations arriving as Double are taken to PatQ at
MARF_DURATION_DENOM resolution, so a reader is still phase-locked to the bar after ten
thousand stages; a Double accumulator would not be. A duration is floored at
MARF_MIN_DURATION so that a zero or negative sample cannot stall the walk.
Sampling a continuous field pattern needs an arc rather than an instant, and the probe used is
[Tₖ, Tₖ + 1/65536), so duration = sine().range(0.25, 2.0) reads the sine essentially at the
stage boundary. A discrete field pattern is unaffected by the probe width: the latch picks the
event containing Tₖ however finely the source is subdivided.
Local time
MarfSample is the temporal context, and the reason it exists is that several notions of time
are live at once. cycle is the global coordinate, stageIndex counts stages, stageTime is
cycles since this stage began and stagePhase is that normalised to 0..1. A curve is written
against stagePhase and never has to reconstruct it:
MarfCurve.Shape("chirp") { p -> 0.5 + 0.5 * kotlin.math.sin(p * p * 40.0) }
The analogy worth keeping is a shader. A fragment shader computes over uv; a stage computes
over stagePhase, inside a structure the pattern algebra laid out.
Continuous address
The address is a PatPattern<Double>, not a PatPattern<Int>, and a fractional address blends.
At address 1.35 the reader evaluates stages 1 and 2 over the same local phase and mixes the
outputs (1-α)·f₁(p) + α·f₂(p) with α = 0.35, blending their durations the same way. So
b.read(sine().range(0.0, 4.0))
glides through the bank instead of stepping through it, and the bank stops being five slots and starts being a sampled one-dimensional family of functions. The entry value is shared between the blended components, so the output stays continuous across a stage boundary exactly as it does for an integer address.
There is a catch, and it is arithmetic rather than a bug. A periodic address is latched once per stage, so a reader with periodic durations visits exactly
lcm(address period, stage period) / stage period
distinct addresses and then repeats forever. sine().range(0, 4) against quarter-cycle stages is
four of them — the address cycles 2, 4, 2, 0, 2, 4, 2, 0, … and the "glide" is a four-step
sequencer wearing a signal's clothes. sine().slow(3) is twelve, which is better and still a
sequence. What actually reads as a glide is a long count: saw().slow(8) against those stages is
thirty-two, and an aperiodic address (perlin(), rand()) never repeats at all. No exception is
raised in any of these cases, so the symptom is what you hear rather than what you read.
Count the distinct MarfSample.address values rounded when you check. An aliased address
repeats to a few parts in 10⁴ rather than bit-exactly, because the probe instant is an exact
rational and PatQ.toDouble() rounds it at twelve places — a raw distinct() reports seventeen
different addresses for the four-value cycle above. MarfSmokeTest prints both cases.
Values other than numbers
A stage's value is a type parameter, and MarfSpace is the arithmetic a reader needs to move
one value toward another: MarfSpace.sub for the direction, MarfSpace.scale to walk part of
it, MarfSpace.add to arrive. The curve stays scalar, because a stage's shape is
one-dimensional however many dimensions its value has.
val path = marfBankIn(
MarfVector3f,
stageTo(Vector3f(0f, 0f, 0f), 1.0),
stageTo(Vector3f(1f, 0.5f, 0f), 0.5, MarfCurve.Smooth)
).read(pat("<0 1>").nums(), fields = MarfFieldClock.STAGES)
path.at(1.5).value // a Vector3f
path.signal { it.x.toDouble() } // any component, as an ordinary signal
marf(...) and marfBank(...) are the scalar spellings and give a MarfReader<Double>;
marfIn and marfBankIn take a space. Instances for Field's generated linalg vectors are in
MarfVectors.kt, and they copy before they write — the in-place add/mul/normalize members
would rewrite the bank's own stage targets rather than produce one wrong frame.
MarfSpace is a vector space, which rules out the types that are not one: a quaternion
interpolates by slerp and a hue wraps, so neither is add/scale and neither has an instance.
Those want a reader whose blend is its own function.
Nesting a pattern inside a stage
A stage's continuous behaviour is a MarfCurve, and MarfCurve.Shape can close over anything,
including another reader. What is not modelled is a nested pattern with a local temporal scope
of its own: stagePhase reaches a Shape as a Double, so a pattern read in there has no
evaluation context to be slowed, reversed or thinned in.
src/jvmMain/kotlin/afield/audio/synth/Marf.kt — read the source
Used in: A pattern as a function generator
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val MARF_DURATION_DENOM: Long |
Resolution at which a Double duration becomes an exact PatQ. See the file header. |
val MARF_MIN_DURATION: PatQ |
The floor on a stage duration, in cycles, so that a bad sample cannot stall the walk. |
MarfCurve
The shape of one stage's traversal, as data rather than as a lambda.
| Declaration | Summary |
|---|---|
data object Hold : MarfCurve |
Step to the target on entry and stay there — the sample-and-hold stage. shape ≡ 1. |
data object Linear : MarfCurve |
shape(p) = p. |
data object Smooth : MarfCurve |
Smoothstep, p²(3-2p) — zero slope at both ends, so stages join without a corner. |
data class Exp(val amount: Double = 3.0) : MarfCurve |
shape(p) = p^amount. Above 1 is a slow start and a fast arrival, below 1 the reverse; exp in marfCurve is Exp(3.0) and log is Exp(1/3). |
data class Osc(val cycles: Double = 1.0) : MarfCurve |
A raised cosine, 0.5 - 0.5·cos(2π·cycles·p). |
data class Step(val treads: Int = 4) : MarfCurve |
A staircase in treads equal treads, reaching the target on the last one. |
data class Shape(val name: String, val f: (phase: Double) -> Double) : MarfCurve |
An arbitrary shaping function of local stage phase. |
MarfSpace
The arithmetic a reader needs in order to move a value toward another one.
| Declaration | Summary |
|---|---|
val zero: T |
The additive identity, and the default departure value of a reader's first stage. |
fun add(a: T, b: T): T |
|
fun sub(a: T, b: T): T |
|
fun scale(a: T, k: Double): T |
|
fun lerp(from: T, to: T, fraction: Double): T |
from moved a fraction of the way toward to. Exactly from at 0 and to at 1. |
fun label(value: T): String |
How a stage of this type prints in a figure or a trigger event. |
MarfDoubles
object MarfDoubles : MarfSpace<Double>
The scalar space. The default everywhere, and what marf(...) and marfBank(...) produce.
MarfStage
One timed function segment: where to go, how long to take, and the shape of the going.
| Declaration | Summary |
|---|---|
val target: T |
|
val duration: PatQ |
|
val curve: MarfCurve |
|
val trigger: Boolean |
|
val label: String? |
MarfBank
class MarfBank<T>(val stages: List<MarfStage<T>>, val space: MarfSpace<T>)
Stored temporal material: a list of stages that several readers can traverse independently.
| Declaration | Summary |
|---|---|
val size: Int |
|
operator fun get(index: Int): MarfStage<T> |
|
fun wrapIndex(index: Int): Int |
Bank indices wrap, so an address pattern never has to know how long the bank is. |
override fun toString(): String |
MarfComponent
data class MarfComponent<T>(val index: Int, val stage: MarfStage<T>, val weight: Double)
One bank stage and its weight in a blended (fractional-address) read.
MarfResolvedStage
A stage as the reader actually entered it: resolved numbers, an exact span, and a departure value.
| Declaration | Summary |
|---|---|
val index: Long |
|
val start: PatQ |
|
val duration: PatQ |
|
val address: Double |
|
val target: T |
|
val trigger: Boolean |
|
val label: String |
|
val from: T |
|
val components: List<MarfComponent<T>> |
|
val space: MarfSpace<T> |
|
val end: PatQ |
The exclusive end of this stage, in cycles. |
val arc: PatArc |
The half-open span this stage occupies. |
fun valueAt(phase: Double): T |
The blended output at local phase. |
val exitValue: T |
The value this stage leaves behind, which is the next stage's from. |
MarfSample
Everything a reader knows at one instant — the temporal context a curve or a box is written against.
MarfReader
A traversal of stored material: its own address, its own timing, its own phase, its own state.
| Declaration | Summary |
|---|---|
val name: String |
|
val origin: PatQ |
|
val initial: T |
|
val space: MarfSpace<T> |
|
val fieldClock: MarfFieldClock |
The clock this reader's fields were built against. |
fun reset() |
Drops the resolved timeline. Only matters for a reader whose fields read live box state. |
fun stageAt(t: PatQ): MarfResolvedStage<T> |
The stage active at exact cycle t; before origin, stage 0 at phase 0. |
fun sample(t: PatQ): MarfSample<T> |
The full temporal context at exact cycle t. |
fun at(cycle: Number): MarfSample<T> |
The temporal context at cycle cycle. The form to call from a draw loop. |
fun atSeconds(seconds: Number, cps: Number = 1.0): MarfSample<T> |
The temporal context seconds into a performance at cps cycles per second. |
fun value(cycle: Number): T |
Just the output, at cycle cycle. |
fun signal(component: (T) -> Double): PatPattern<Double> |
One scalar component of the output, as a signal in the sense of PatSignals.kt. |
fun phase(): PatPattern<Double> |
The local stage phase as a signal — a 0..1 sawtooth whose teeth are the stage durations. |
fun address(): PatPattern<Double> |
The current address as a signal. Useful mostly for drawing what a reader is doing. |
fun stageIndex(): PatPattern<Double> |
The current stage index as a signal. |
fun stagesIn(arc: PatArc): List<MarfResolvedStage<T>> |
Every stage overlapping arc, in order — the drawing and testing surface. |
fun triggers(): PatPattern<String> |
Stage boundaries as a discrete pattern: one event per stage, spanning that stage, valued with its label. |
override fun toString(): String |
MarfFieldClock
Which time coordinate a reader's field patterns are read in.
Constants: CYCLES, STAGES, STEPS, AUTO.
MarfGallery.kt
Headless figure gallery for the function-generation layer in Marf.kt, and the check that
every figure on marf.md is a measurement of the reader it claims to draw.
Output is SVG, like the piano rolls in PatPianoRollGallery.kt and for the same reason: a
stage plot is line art, so drawing it as vector geometry makes it diffable — a changed curve is a
changed polyline in the file rather than a changed PNG blob. No AWT, no browser, no GPU, no Field
window.
A MARF figure is not a piano roll, which is why it has its own renderer. A roll draws events; a reader has a continuous output and a stage structure, and what a figure has to show is how the two relate. So a reader's lane draws four things at one shared time scale:
- the output, as a polyline densely sampled through
MarfReader.at; - the stage boundaries, as dashed verticals, one per stage the reader actually entered;
- the address in force during each stage, printed in the stage's own span (
~when the address fell between two slots); - a pulse tick where the stage emitted one, taken from
MarfReader.triggers.
Reading down a column therefore compares the same instant across every lane, and a figure with
three lanes over one bank is the architecture's whole claim in one picture. A lane may also hold a
plain signal rather than a reader — that is how the hold / ramp / interp figure is drawn,
since those conversions produce signals and have no stages to show.
What every figure guarantees
Every figure is drawn from the readers it claims to draw and held to them: one boundary vertical per stage, one polyline point per sample, one pulse tick per triggering stage, no stage starting past the plotted window, and no non-finite output. So a figure can neither invent a stage nor lose one.
Two of those properties are about the layer rather than the drawing:
- the output is continuous across every interior stage boundary whose arriving stage does not
step — exactly, comparing the departing stage's value at phase 1 against the arriving stage's
at phase 0, with no tolerance. This is what makes a bank of stages an envelope generator rather
than a list of disconnected ramps, and it is invisible at the resolution the figure draws. A
stage steps exactly when its curve's shape is non-zero at phase 0, which is what
holdis, so those boundaries are skipped by that test rather than by hand; - a figure whose point is that two readers differ really does draw two different curves, so "one bank, three journeys" cannot become three copies of one journey.
Each figure then runs its own verify over the stages it drew, which is where the specific claim
lives — that the patterned durations really are 1, 4, ¼ and 2 cycles exactly; that
every(4) { rev() } reverses the first cycle's addresses and leaves the next three; that a rest
in a euclidean address holds the previous slot rather than snapping to zero.
Run the lot with tools/run_field_tests.py --id synth.marf.gallery; every figure renders to a
temp dir and nothing committed is written. Pass an output dir as args[0] (or set
MARF_FIGURE_DIR) to regenerate the committed figures under public/docs/assets/marf/.
Add a figure by appending one Figure to FIGURES; MarfDocsGuardTest then requires
public/docs/marf.md to reference it, and requires every committed SVG to belong to one.
src/jvmMain/kotlin/afield/audio/synth/MarfGallery.kt — read the source
MarfGallery
Renders every Figure in FIGURES to SVG and asserts each one against the readers that drew it.
MarfSmokeTest.kt
The worked example for Marf.kt, and the check that it holds together: one bank, several
readers, and what each reader is doing printed as text.
main prints five rows per reader — current address, current stage, stage phase, generated
value and stage-boundary pulses — for two readers over shared material, then swaps
every(4) { rev() } into one reader's address and prints them again. Content, timing, addressing
and interpolation are separate, so transforming one reader's address cannot disturb the other and
cannot alter the bank; the run checks both.
The transform itself only bites on an address with steps inside the cycle: on a <…>
address, which holds one event per cycle, rev() has nothing to reverse, so the run repeats the
comparison over four stages a cycle and asserts that cycle 0 comes out reversed and cycle 1 does
not.
The livecoding version
The same patch in a box, driving sound. marfBank is the material; the three readers are three
journeys through it; nothing about the bank knows it is being read three times:
val b = marfBank(
rampTo(0.1, 1.0), rampTo(0.9, 0.5), holdAt(0.4, 2.0),
oscTo(0.7, 1.0, cycles = 3.0), curveTo(0.2, 0.25, amount = 4.0)
)
val slowArc = b.read(pat("<0 1 2 1 3 4>").nums(), name = "arc")
val fastArc = b.read(pat("<0 2 4 1 3>").nums().slow(3), speed = constant(4.0), name = "fast")
val glide = b.read(sine().range(0.0, 4.0), duration = constant(0.25), name = "glide")
// Continuous out: three readers of one bank as three parameters of one voice.
val voice = pat("bd*8")
.with(gain(fastArc.signal().range(0.25, 1.0)))
.with(pan(glide.signal()))
.with(speed(slowArc.signal().range(0.5, 2.0)))
voice.respond(cps = 1.0) { patVoice(it) }
// Discrete out, from the same temporal structure: one pulse per stage.
slowArc.triggers().respond(cps = 1.0) { println("stage ${it.value}") }
Then change one line and nothing else:
val slowArc = b.read(pat("<0 1 2 1 3 4>").nums().every(4) { it.rev() }) // periodic reversal
val slowArc = b.read(struct(euclid(5, 8), pat("0 4 2 3").nums())) // euclidean trajectory
val slowArc = b.read(sine().range(0.0, 5.0)) // glide, not step
The graphics version
Driving geometry shows the architecture more directly than driving notes does. Three readers share one bank and become three coordinates, so the shapes are related without being synchronised:
val x = b.read(pat("<0 1 2 3 4>").nums(), name = "x")
val y = b.read(pat("<0 2 4 1 3>").nums().slow(3), name = "y")
val r = b.read(sine().range(0.0, 4.0), duration = constant(0.5), name = "r")
frames {
val t = musicClock().now
val p = vec2(x.atSeconds(t).value, y.atSeconds(t).value) * 400.0
ln.clear().circle(p, 10.0 + 60.0 * r.atSeconds(t).value)
}
A frames { } block needs a renderer to tick at all, so that last one belongs in a box with a
graphics parent rather than here, which stays headless.
src/jvmMain/kotlin/afield/audio/synth/MarfSmokeTest.kt — read the source
MarfSmokeTest
Prints two readers of one shared bank as text, then asserts what transforming one reader's address may and may not change.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
MarfVectors.kt
MarfSpace instances for Field's generated linalg vectors, so a reader can emit a position, a
colour or a set of material parameters instead of one number.
Everything in Marf.kt is generic over the value type already; this file supplies the arithmetic
for the three types most stage material actually is. The reason it is a file of its own is the
dependency direction: the core layer needs nothing but PatPattern, and keeping afield.math.linalg
out of it means a reader over some other type costs one object rather than an edit to the layer.
val trajectory = marfBankIn(
MarfVector3f,
stageTo(Vector3f(0f, 0f, 0f), 1.0),
stageTo(Vector3f(1f, 0.5f, 0f), 0.5, MarfCurve.Smooth),
stageTo(Vector3f(0f, 1f, 1f), 2.0, MarfCurve.Exp(3.0))
)
val path = trajectory.read(pat("<0 1 2>").nums(), fields = MarfFieldClock.STAGES)
path.at(3.5).value // a Vector3f
path.signal { it.x.toDouble() } // and any component as an ordinary signal
Every instance copies before it writes
This matters more here than the arithmetic does. In afield.math.linalg, add(other),
mul(scalar), sub(other) and normalize() are in-place members that return the receiver,
and / resolves to the mutating member operator rather than to the allocating extension. A stage
target lives in a bank and is read once per stage for as long as the reader runs, so a single
in-place write would corrupt the material rather than produce a wrong frame:
a.add(b) // WRONG: mutates a, which is the bank's stored target
a.add(b, Vector3f()) // right: fresh destination, a and b untouched
Every method below uses the out-parameter form. MarfVectorSpaceTest reads a bank twice and
requires the second pass to agree with the first, which is the property that a mutating
implementation breaks and a type check does not.
src/jvmMain/kotlin/afield/audio/synth/MarfVectors.kt — read the source
MarfVector2f
object MarfVector2f : MarfSpace<Vector2f>
2-component vector stages. Screen positions, UV coordinates, a pan/tilt pair.
MarfVector3f
object MarfVector3f : MarfSpace<Vector3f>
3-component vector stages. Positions, directions, RGB.
MarfVector4f
object MarfVector4f : MarfSpace<Vector4f>
4-component vector stages. RGBA, a quaternion's storage, four packed material parameters.
| Declaration | Summary |
|---|---|
override val zero: Vector4f |
|
override fun add(a: Vector4f, b: Vector4f): Vector4f |
|
override fun sub(a: Vector4f, b: Vector4f): Vector4f |
|
override fun scale(a: Vector4f, k: Double): Vector4f |
|
override fun lerp(from: Vector4f, to: Vector4f, fraction: Double): Vector4f |
|
override fun label(value: Vector4f): String |
|
const val QUATERNIONS_ARE_NOT_A_VECTOR_SPACE: String |
Note on quaternions: a Vector4f reader over four quaternion components interpolates componentwise and then does not renormalise, which is nlerp without the n and is not a rotation path. Read the four numbers and normalise at the point of use, or write a reader whose blend is slerp — see the note on vector spaces in MarfSpace. |
NotationPatterns.kt
Turns a region of the drawing layer into a pattern: rectangles, level strokes and moving strokes become notes, with x as time and y as MIDI pitch.
src/jvmMain/kotlin/afield/audio/synth/NotationPatterns.kt — read the source
Used in: A Drawn Score, Heard, A Drawn Morph, Heard
Top-level functions
| Declaration | Summary |
|---|---|
fun NotationSpace.toPattern(glideStep: Double = 0.125, levelTolerance: Double = 0.5): PatPattern<String> |
A drawn score, as a pattern: the bridge from notation.space(...) to everything that plays a PatPattern — patRenderStereo offline, a PatSynth live. |
fun patTimed(notes: List<TimedNote>, beats: Double): PatPattern<String> |
A pattern from notes with explicit times: one cycle is beats long and repeats, so a note at beat 2 of an eight-beat cycle plays at a quarter of every cycle. |
fun NotationSpace.notes(glideStep: Double = 0.125, levelTolerance: Double = 0.5): List<DrawnNote> |
The notes toPattern plays, in start order — the same reading, before it becomes a pattern. |
TimedNote
data class TimedNote(val start: Double, val end: Double, val note: Double, val gain: Double = 1.0)
One note of a patTimed pattern: it sounds from start to end, in beats from the start of the cycle, at MIDI note (fractional notes are microtones), with gain 0..1.
DrawnNote
One note a drawn score plays: start and end in the space's x units, pitch in its y units. gain is the pen pressure where the mark recorded one (0..1), else null.
| Declaration | Summary |
|---|---|
val duration: Double |
|
override fun toString(): String |
Orca.kt
ORCA, the two-dimensional livecoding sequencer, running in a box output with its notes, control changes, UDP and OSC
messages delivered to Kotlin callbacks; start it with orca.
src/jvmMain/kotlin/afield/audio/synth/Orca.kt — read the source
Used in: ORCA
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.orca(channel: String = "orca", grid: String? = null, bpm: Int? = null, cols: Int = 64, rows: Int = 24, guide: Boolean = false): Orca |
ORCA, Hundred Rabbits' two-dimensional livecoding sequencer, running in one of this box's outputs, with everything it plays coming back to Kotlin. |
fun PatSynthBank.play(note: OrcaNote, pan: Double = 0.5, wave: PatWave = voice.wave, gain: Double = 1.0, cutoffHz: Double? = null) |
Play note on this bank at OrcaNote.time: held for ORCA's length, at its velocity. The bank has to be playing — returned from the box — for the note to be heard. |
OrcaNote
One : (or %, mono) note. frames is ORCA's length; seconds is that at the current bpm.
| Declaration | Summary |
|---|---|
val channel: Int |
|
val octave: Int |
|
val note: String |
The note as ORCA writes it: C to B, lowercase for sharps, and its wrapped letters. |
val midi: Int |
|
val velocity: Double |
0 to 1; ORCA's velocity digit over 16. |
val frames: Int |
|
val seconds: Double |
|
val mono: Boolean |
|
val frame: Int |
The ORCA frame that played it. |
val time: Long |
The System.nanoTime it should sound at: when ORCA played it, plus Orca.latency. |
val hz: Double |
OrcaCC
data class OrcaCC(val channel: Int, val knob: Int, val value: Int, val frame: Int)
One ! control change; value is already scaled to 0..127, knob already offset as ORCA sends it.
OrcaBend
data class OrcaBend(val channel: Int, val lsb: Int, val msb: Int, val frame: Int)
One ? pitch bend, as the two 7-bit halves ORCA sends.
OrcaCell
data class OrcaCell(val x: Int, val y: Int, val before: Char, val after: Char)
A cell that changed between two reports.
OrcaOsc
data class OrcaOsc(val path: String, val args: List<Int>, val text: String, val frame: Int)
One = message: / plus its path, and the values of the glyphs after it.
Orca
An ORCA: what it is doing, what it plays, and how to change it. Box.orca makes one in a box's output; OrcaHeadless makes one in the JVM with no page, for rendering and tests.
| Declaration | Summary |
|---|---|
val channel: String |
|
var latency: Double |
How far behind ORCA a note's OrcaNote.time is set, in seconds: room for the trip to Field. |
var grid: String |
The grid as ORCA last reported it, rows separated by newlines. Setting it replaces the whole grid; short rows are padded with empty cells, and common indentation is removed. |
var width: Int |
|
var height: Int |
|
var frame: Int |
|
var playing: Boolean |
|
var bpm: Int |
ORCA's tempo; setting it is the commander's bpm:, clamped by ORCA to 60..300. |
var variables: Map<String, String> |
This frame's V variables. |
var cursor: Pair<Int, Int> |
Where ORCA's cursor is. |
var reports: Long |
Reports received from the page; zero until ORCA has started. |
var operators: String |
ORCA's own operator reference, as its page generated it. |
val recent: List<String> |
The last 64 messages ORCA sent, one line each, newest last. |
fun write(x: Int, y: Int, text: String) |
Put text on the grid with its top-left at (x, y); newlines start a new row at x. |
operator fun get(x: Int, y: Int): Char |
The glyph at (x, y), '.' for an empty cell or one off the grid. It is the grid ORCA last reported, with any cell this box has set since then showing the value it was set to. |
operator fun set(x: Int, y: Int, glyph: Char) |
Put one glyph at (x, y) at ORCA's next frame. Unlike write it stays out of ORCA's undo history and is not saved as an edit, so a box can set cells every frame: o[4, 2] = 'C'. |
suspend fun eval(javascript: String, timeoutMillis: Long = 5_000): JsonElement |
Run javascript in ORCA's page, at global scope beside ORCA's client and library, and suspend until its value comes back as JSON with the next report; a promise is awaited first. Anything goes: read ORCA's state, restyle it, replace an operator in library, wrap client.run. A JavaScript error comes back as an IllegalStateException carrying its stack. |
fun js(javascript: String) |
Run javascript in ORCA's page at its next frame, as eval does, without waiting for a value. |
fun bang(x: Int, y: Int) |
Put a * at (x, y): next frame, the operators beside it fire, and ORCA erases it. |
fun variable(name: Char, value: Char?) |
Hold ORCA variable name at value every frame, as if a V on the grid wrote it, so V reads it there; null lets it go. A V on the grid that writes the same name wins that frame. |
fun command(text: String) |
Run a commander string, as if typed after Cmd-K: "bpm:140", "write:D8;4;2", "find:aa", ... |
fun play() |
|
fun stop() |
|
fun onNote(callback: (OrcaNote) -> Unit) |
|
fun onNote(name: String, callback: (OrcaNote) -> Unit) |
Replace this named note callback; Stop or rerun of its registering box removes it. |
fun onCC(callback: (OrcaCC) -> Unit) |
|
fun onBend(callback: (OrcaBend) -> Unit) |
|
fun onUdp(callback: (String) -> Unit) |
|
fun onOsc(callback: (OrcaOsc) -> Unit) |
|
fun onFrame(callback: (Orca) -> Unit) |
After every report: once a frame while ORCA plays, a few times a second while it is paused. |
fun onChange(callback: (OrcaCell) -> Unit) |
Every cell that changed since the last report, whoever changed it: an operator, the keyboard, this box. Called row by row, before onFrame. A report can cover more than one frame when the page is busy, so a cell that changed and changed back in between is not seen. |
fun onChange(name: String, callback: (OrcaCell) -> Unit) |
Replace this named change callback; owned by the registering box run. |
fun onCell(x: Int, y: Int, callback: (Char) -> Unit) |
onChange, for one cell: callback gets the glyph it changed to. |
fun onCell(x: Int, y: Int, name: String, callback: (Char) -> Unit) |
A named, run-owned cell callback; replacing it does not accumulate listeners. |
suspend fun sync(timeoutMillis: Long = 3_000): Boolean |
Suspend until ORCA has applied everything sent so far and reported the result, or timeoutMillis passes. Commands travel in the reply to one report, so their effect shows in the one after. |
val page: LiveOutputIframe |
The output's iframe, for the live-iframe bridge's evaluate, screenshot, ... in the Electron app. |
suspend fun picture(timeoutMillis: Long = 5_000): String |
ORCA as the page playing it has drawn it, as a PNG data URL at most 1400 pixels wide. It comes from ORCA's own canvas, through ORCA's connection, so it works in any browser and shows exactly what is on the screen; it is drawn fresh first, in case the page is in the background. |
val numberedGrid: String |
The grid with each row prefixed by its y, as AI4's tools show it. Empty cells at the ends of rows, and empty rows at the bottom, are left off: they move nothing, and a window-wide grid is mostly them. |
fun exposeTools(name: String = "orca", about: String = "", iframeTools: Boolean = false): List<BoxTool> |
Let the document's AI4 play this ORCA, the way the paint studio lets it paint. AI4 gets tools to look at the board (<name>_look: the picture ORCA drew, and the grid as text), read it, write cells while it plays, replace it, command it and run JavaScript in its page; the board, the tempo and how to play are in its context every turn; and /orca shows the board in the chat. |
OrcaHeadless.kt
Runs ORCA's own operators in the JVM without a browser page, one frame per step, so an ORCA program can be tested or rendered offline.
src/jvmMain/kotlin/afield/audio/synth/OrcaHeadless.kt — read the source
OrcaHeadless
ORCA with no page: its own operators, from the same vendored scripts orca() loads in a box's output, run in the JVM through GraalJS one frame at a time, as fast as you step it.
| Declaration | Summary |
|---|---|
val orca |
This ORCA: callbacks, cells, variables, commands, exactly as for one in a box's output. |
fun step(frames: Int = 1) |
Run frames ORCA frames, calling orca's callbacks after each. |
fun evaluate(javascript: String): JsonElement |
Run javascript in this ORCA's context now, at global scope beside client and library, and return its value as JSON. The headless counterpart of Orca.eval, which waits for a page. |
val frameSeconds: Double |
The length of one frame at the current tempo, in seconds. |
override fun close() |
OrcaSoundDocs.kt
Offline renders of the ORCA page's two sound examples, with each note placed on the sample its ORCA frame falls on.
src/jvmMain/kotlin/afield/audio/synth/OrcaSoundDocs.kt — read the source
OrcaSoundDocs
The sound on the ORCA page, rendered offline: ORCA's own operators running in OrcaHeadless, frame by frame, voiced by the same PatSynthBank calls a box makes live.
| Declaration | Summary |
|---|---|
const val SR |
|
val HELLO |
|
val LEAD |
|
val ECHO |
|
class Rendered(val left: FloatArray, val right: FloatArray, val notes: List<OrcaNote>) |
A rendered scene: the notes ORCA played, and the audio they made. |
fun render(name: String): Rendered |
|
fun renderWav(name: String, outPath: String) |
|
val DRONE_PATCH |
PatAlgebra.kt
The parameter-application algebra: combining patterns of different types, and choosing whose rhythm survives.
This is the piece that turns the event language in TidalPatterns.kt into a composition
language. Everything else — signals, scales, chords, off, jux — is built on top of the
three combinators here.
Read the surface from the outside in:
-
Structure choice. When two patterns meet, one of three things can happen to the rhythm:
pat("0 1").nums()
|+|pat("10 20 30").nums() // structure from BOTH: 4 events pat("0 1").nums()|+pat("10 20 30").nums() // structure from LEFT: 2 events pat("0 1").nums()+|pat("10 20 30").nums() // structure from RIGHT: 3 events
The bars mark where the structure comes from, exactly as in Tidal. Every operator also has
a prose alias (addBoth, addLeft, addRight) for people who dislike backticks.
-
Taking a value rather than combining:
pat("bd sd")
#pat("a b c") // left's rhythm, right's values (Tidal's|>) pat("bd sd")|Lpat("a b c") // left's rhythm, left's values (Tidal's|<) -
Named parameters, which is what you actually reach for:
val voice = pat("bd sd hh sd") with gain(sine().range(0.3, 1.0)) with pan(saw())
A parameter is stored in the event's existing annotations map, so it survives every
transform in the language, arrives intact in PatScheduledEvent, and reads back typed:
1 | |
Structure choice, precisely
-
appBoth— the cross product of every pair of intersecting events; each output arc is the intersection. This is the only one of the three that can create events. -
appLeft— exactly the left pattern's events survive, one output per input. Each left event queries the right pattern over its own arc and takes the right event containing the left arc's start. -
appRight— the mirror image.
The "query the right over the left's arc, latch at the left's start" rule is the one place this
language knowingly diverges from Tidal, and the reason is the event type. Tidal splits the left event
into fragments, one per overlapping right event, because its events carry both a whole and a
part. PatEvent has a single arc, so fragmenting here would silently multiply notes: ask for
four kicks with a sixteen-step filter sweep and you would get sixty-four kicks. Instead:
- a discrete right pattern latches its value at note onset, which is what a synth voice does anyway (parameters are read when the envelope is triggered);
- a continuous right pattern answers the left event's whole arc with a single event whose
value is the midpoint of that note, so
gain(sine())gives one gain per note, taken at the middle of the note.
One implementation, both behaviours, and note count is preserved. PatAlgebraTest asserts the
cardinality laws and the midpoint sampling in exact rational cycle space.
Because appLeft never fragments, sub-cycle queries sample the clipped fragment's start.
Every scheduling path in the language (eventSequence, scheduled, peek) queries whole
cycles, so this only matters if you call events(...) with a partial arc by hand.
src/jvmMain/kotlin/afield/audio/synth/PatAlgebra.kt — read the source
Used in: A pattern in space: pat → Dough → Steam Audio, A pattern as a function generator, OSCQuery: controls other tools can find
Top-level functions
PatParam
class PatParam internal constructor(val key: String, internal val values: PatPattern<String>)
A named parameter pattern waiting to be attached to some atoms.
| Declaration | Summary |
|---|---|
override fun toString(): String |
PatClock.kt
The transport everything scheduled in afield.audio.synth reads: one monotonic number that can be
paused, sought and subdivided, so that patterns, Boolean networks and audio launches share a
single notion of the current time.
A PatClock reports that number as now, in the clock's own units. The shared clock advances
one unit per second of wall time, so a unit is a second until you say otherwise. There is no
tempo field: a faster grid comes from subdivide, which returns a child reading exactly factor
times its parent and following the parent's pauses and seeks.
The constructor is internal, so a box reaches a clock two ways. musicClock() returns the one
process-wide clock, built on first call. Box.patClock is an inherited property, and the
metaroot is given the shared clock when it is created, so a box reads whatever its nearest
ancestor holds. Assigning patClock writes locally, which puts that box and everything under it
on a different timeline and leaves the rest of the document on the old one.
Every scheduling entry point in this package spells its clock default the same way, the box's
inherited patClock and then the shared clock. The two respond families accept null there
instead, and their callback scope then measures time as seconds since the launch, which nothing
can pause or seek.
Each method synchronises on one lock and returns the clock, so a setup line reads as one expression:
val clock = musicClock().reset(started = true)
val sixteenths = clock.subdivide(4.0, label = "16ths")
src/jvmMain/kotlin/afield/audio/synth/PatClock.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun musicClock(started: Boolean = true): PatClock |
The shared clock, started unless you say otherwise. |
Top-level values
| Declaration | Summary |
|---|---|
var Box.patClock: PatClock |
The clock this box schedules against, inherited from the nearest ancestor that has one. |
PatClock
A pausable, seekable clock: now is the reading the clock was last set to plus the time that has passed since, scaled by rate and accumulated only while the clock is running.
| Declaration | Summary |
|---|---|
val rate: Double |
|
val label: String? |
|
val isRunning: Boolean |
Whether the clock is currently accumulating time. A paused clock still reports its now. |
val now: Double |
The clock's current reading, in the clock's own units. |
fun start(at: Number? = null): PatClock |
Start accumulating time, optionally setting the reading to at first. |
fun pause(): PatClock |
Stop accumulating time, keeping the current reading. start resumes from there. |
fun toggle(): PatClock |
pause when running, start when not. The two calls are not atomic with each other. |
fun seek(time: Number): PatClock |
Jump the reading to time, leaving the running state alone. |
fun reset(started: Boolean = false): PatClock |
Set the reading to zero and the running state to started. |
fun subdivide(factor: Number, label: String? = null, started: Boolean = isRunning): PatClock |
A child clock running factor times as fast as this one, seeded so that it reads this.now * factor. |
override fun toString(): String |
PatComposition.kt
Compositional structure: the transforms that build a piece out of a pattern, and the pitch vocabulary — scales, named chords, and voice leading.
TidalPatterns.kt gives you one line of music and the means to vary it. PatAlgebra.kt lets two lines meet. This file is what you reach for when you want an arrangement: the same material displaced, superimposed, rotated, split across the stereo field, and spelled in a key.
Read the surface from the outside in:
-
Displace and layer:
pat("bd ~ sd ~") .off(PatQ.of(1, 8)) { it with gain(0.4) } // a ghost note an eighth later .superimpose { it.fast(2) } .jux { it.rev() } // left ear forward, right ear reversed
-
Rotate and hold:
pat("a b c d").iter(4) // cycle 0: a b c d, cycle 1: b c d a, … pat("a b c d").linger(PatQ.of(1, 2)) // just "a b", twice
-
Choose per cycle:
randcat(pat("bd4"), pat("bd sd"), pat("~ sd")) wrandcat(pat("bd4") to 3.0, pat("~ sd") to 1.0) pat("a b c d").shuffle(4)
-
Spell it in a key:
run(8).scale("minor", root = 60) // eight degrees of C minor pat("0 3 4 3").nums().scale("dorian", root = 62)
-
Chords, and the part Tidal has no answer for — voice leading:
val progression = chords("c'maj7 a'min7 d'min7 g'dom7") progression.voiceLead(center = 60).notesOf()
voiceLead picks each chord's inversion and octave to minimise the total semitone movement
of the voices from the previous chord, which for that progression is close to an order of
magnitude less than playing the chords from their roots. The harmony survives it: every chord
comes back as the same set of pitch classes, in the same number of voices, inside the register
asked for. PatCompositionTest asserts those three and that the movement strictly decreases,
and prints the before-and-after figures rather than pinning them.
src/jvmMain/kotlin/afield/audio/synth/PatComposition.kt — read the source
Used in: Patterns: writing rhythm as a function of time, Faust, Lua in a Box
Top-level functions
| Declaration | Summary |
|---|---|
fun <A> PatPattern<A>.off(by: PatQ, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Stacks this pattern with a copy shifted by cycles later and passed through transform. |
fun <A> PatPattern<A>.off(numerator: Int, denominator: Int, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
off with the shift given as a fraction, off(1, 8) { … }. |
fun <A> PatPattern<A>.superimpose(transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Stacks this pattern with a transformed copy of itself, in place. |
fun <A> PatPattern<A>.layer(vararg transforms: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Stacks one transformed copy per transforms entry — superimpose for more than two voices. |
fun <A> PatPattern<A>.jux(transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Hard-pans this pattern left and a transformed copy right, via the pan parameter. |
fun <A> PatPattern<A>.juxBy(width: Number, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
jux with a stereo width in 0..1 — juxBy(0.5) { … } keeps both copies near the centre. |
fun <A> PatPattern<A>.iter(n: Int): PatPattern<A> |
Advances the pattern by 1/n of a cycle on each successive cycle, so it takes n cycles to come back round. pat("a b c d").iter(4) reads a b c d, b c d a, c d a b, d a b c. |
fun <A> PatPattern<A>.iterBack(n: Int): PatPattern<A> |
iter the other way round. |
fun <A> PatPattern<A>.linger(fraction: PatQ): PatPattern<A> |
Plays only the first fraction of each cycle, repeated to fill the cycle. |
fun <A> PatPattern<A>.linger(numerator: Int, denominator: Int): PatPattern<A> |
linger with the fraction given as numerator/denominator. |
fun <A> PatPattern<A>.ply(n: Int): PatPattern<A> |
Repeats each event n times inside its own arc. Tidal's ply. |
fun <A> PatPattern<A>.rot(n: Int): PatPattern<A> |
Rotates the values of each cycle's events by n while leaving the rhythm alone. |
fun <A> PatPattern<A>.shuffle(n: Int, salt: Int = 0): PatPattern<A> |
Slices each cycle into n equal parts and plays them in a deterministic random permutation that changes every cycle. Unlike rot this really is a permutation — no part is dropped or repeated within a cycle. |
fun <A> randcat(vararg patterns: PatPattern<A>, salt: Int = 0): PatPattern<A> |
Plays one of patterns per cycle, chosen deterministically at random. |
fun <A> wrandcat(vararg weighted: Pair<PatPattern<A>, Double>, salt: Int = 0): PatPattern<A> |
Plays one of weighted per cycle, chosen with probability proportional to its weight. |
fun <A> choose(vararg values: A, salt: Int = 0): PatPattern<A> |
A continuous pattern that picks uniformly from values — one choice per query arc. |
fun <A> wchoose(vararg weighted: Pair<A, Double>, salt: Int = 0): PatPattern<A> |
A continuous pattern that picks from weighted proportionally to each weight. |
fun <A> PatPattern<A>.chop(n: Int): PatPattern<A> |
Cuts each event into n consecutive slices in place, annotating each with the begin/end fraction of the original it represents. |
fun <A> PatPattern<A>.striateAcross(n: Int): PatPattern<A> |
Plays the whole pattern n times per cycle, each pass annotated with a different begin/end slice of the source — Tidal's striate. |
fun patScaleSteps(name: String): List<Int> |
The semitone offsets of a named scale, or the chromatic scale if the name is unknown. |
fun patScaleDegree(steps: List<Int>, degree: Int): Int |
Maps one scale degree (any integer, positive or negative) to a semitone offset. |
fun PatPattern<Double>.scale(name: String, root: Int = 60): PatPattern<Double> |
Reads this pattern's values as scale degrees and returns MIDI note numbers. |
fun scale(name: String, degrees: PatPattern<Double>, root: Int = 60): PatPattern<Double> |
Tidal's argument order: scale("minor", run(8)). |
fun patChordSteps(name: String): List<Int> |
The semitone offsets of a named chord, or a bare root if the name is unknown. |
fun patNoteNumber(token: String): Int? |
Reads a MIDI note from a token: a bare number (60), or a note name with optional accidentals and octave (c, cs, ef4, a3). |
fun chords(source: String): PatPattern<List<Int>> |
Parses a chord progression from mini-notation, root'name per atom. |
fun PatPattern<String>.chordsOf(): PatPattern<List<Int>> |
Reads each string atom of this pattern as root'name and returns its MIDI notes. |
fun patParseChord(token: String): List<Int>? |
Parses one root'name token into MIDI notes, or null when the root is unreadable. |
fun PatPattern<Double>.chord(name: String): PatPattern<List<Int>> |
Builds a chord pattern by naming one chord over a pattern of roots. |
fun PatPattern<List<Int>>.notesOf(): PatPattern<Double> |
Flattens a chord pattern into simultaneous single-note events — a block chord. |
fun PatPattern<List<Int>>.arpeggiate(descending: Boolean = false): PatPattern<Double> |
Spreads each chord's notes evenly across its own arc, lowest first. Tidal's arpg. |
fun PatPattern<List<Int>>.voiceLead(center: Int = 60, spread: Int = 12): PatPattern<List<Int>> |
Chooses each chord's inversion and octave to minimise how far the voices have to move from the previous chord. |
fun patLeadVoicing(chord: List<Int>, previous: List<Int>, center: Int, spread: Int): List<Int> |
The single-chord kernel behind voiceLead; exposed so tests can measure it directly. |
fun patVoicingDistance(a: List<Int>, b: List<Int>): Double |
Total semitone movement between two voicings: the sorted voices are paired up, and any extra voice in either chord is charged against the nearest voice of the other. |
fun patTotalVoiceMovement(voicings: List<List<Int>>): Double |
Total voice movement across a chord sequence — the number voiceLead minimises. |
fun <A> squeezeJoin(patterns: PatPattern<PatPattern<A>>): PatPattern<A> |
Flattens a pattern of patterns by compressing each inner pattern into the arc of the outer event that carries it. |
fun <A, B> PatPattern<A>.squeeze(substitute: (A) -> PatPattern<B>): PatPattern<B> |
Replaces each event with a whole pattern, squeezed into that event's arc. |
fun <A> PatPattern<Double>.pick(values: List<A>): PatPattern<A> |
Indexes into values with this numeric pattern, wrapping — the terse way to spell a lookup. |
fun <A> PatPattern<Double>.pick(vararg values: A): PatPattern<A> |
Indexes into values with this numeric pattern, wrapping. |
fun <A> PatPattern<A>.swingBy(amount: PatQ, n: Int): PatPattern<A> |
Swing: delays every second subdivision of a 2n grid by amount of one subdivision. |
fun <A> PatPattern<A>.swing(n: Int): PatPattern<A> |
swingBy a third of a subdivision — the triplet feel, and the one people mean by "swing". |
fun <A> PatPattern<A>.pressBy(amount: PatQ): PatPattern<A> |
Squeezes each event into the later 1 - amount of its own arc, so it lands late and short. |
fun <A> PatPattern<A>.press(): PatPattern<A> |
pressBy a half — every event onto the second half of its slot. |
fun <A> ncat(vararg weighted: Pair<PatPattern<A>, Int>): PatPattern<A> |
Concatenates weighted patterns within each cycle, each taking a share of the cycle proportional to its weight. |
fun <A> PatPattern<A>.unDegradeBy(probability: Double, salt: Int = 0): PatPattern<A> |
Keeps exactly the events degradeBy would have dropped — the complement, on the same hash. |
fun <A> PatPattern<A>.sometimesEventBy(probability: Double, salt: Int = 0, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Applies transform to a random subset of the events, leaving the rest alone. |
fun <A> PatPattern<A>.sometimesEvent(transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
sometimesEventBy with an even chance. |
fun <A> PatPattern<A>.someCyclesBy(probability: Double, salt: Int = 0, transform: (PatPattern<A>) -> PatPattern<A>): PatPattern<A> |
Tidal's name for what sometimesBy in TidalPatterns.kt already does: transform whole cycles at random. Provided so searching for the Tidal name finds the right function. |
fun euclidInv(pulses: Int, steps: Int, rotation: Int = 0): PatPattern<Boolean> |
The inverse Euclidean rhythm: true exactly where euclid is false. |
fun <A> euclidFull(pulses: Int, steps: Int, on: PatPattern<A>, off: PatPattern<A>, rotation: Int = 0): PatPattern<A> |
on on the Euclidean onsets, off on every other step — a full bar from one figure. |
fun PatPattern<Double>.toScale(steps: List<Int>, root: Int = 60): PatPattern<Double> |
Reads this pattern's values as degrees of an arbitrary scale given as semitone offsets. |
Top-level values
| Declaration | Summary |
|---|---|
val PAT_SCALES: Map<String, List<Int>> |
Named scales, as semitone offsets from the root. |
val PAT_CHORDS: Map<String, List<Int>> |
Named chords, as semitone offsets from the chord's root. |
PatCondense.kt
The shortest mini-notation expression that plays a given rhythm.
PatTranscription.kt writes a rhythm out flat: patNotationOf fits a step grid to a set of onsets
and emits one slot per step, x . . x . . x ., which is faithful and says nothing about what it is
looking at. bd(3,8) says the same thing in a third of the characters and names the figure.
This file gets from the first spelling to the second. Give it a rhythm, as a mask, a PatPattern, a
list of events or a PatTranscription, and it returns the shortest string in Field's own
mini-notation whose parse renders to an identical event set, together with the structure it
found.
Description length is the whole criterion. There is no notion here of a "better" reading of a
rhythm than a shorter one. x ~ ~ x ~ ~ x ~ is x(3,8) because that is six characters against
fifteen, not because Euclidean rhythms are privileged, and a figure that no operator shortens
comes back as itself, spelled out. PatCondenseTest asserts that last part over generated
structureless masks, since a condenser that always found something would be finding nothing.
The oracle is Field's own parser. Every candidate this file emits is handed back to pat, the
same entry point a box calls, re-rendered, and compared event for event against the target. Nothing
is accepted on the strength of the reasoning that produced it, and there is no second hand-written
evaluator to drift away from the language. The proposers below are allowed to be wrong, because
being wrong only costs a rejected candidate.
What it searches. The candidate grammar is read off MiniPat.Node (TidalPatterns.kt:1127) —
what the parser actually accepts, not what a mini-notation tutorial says. Atoms and ~; _ holds
and their @n spelling; [ … ] grouping at every divisor of the span; x!n replication; x*n
repetition; X(p,s,r) Euclidean figures and X(kN) / X(gN) Kolakoski ones over any repeated
term rather than only a repeated atom; a, b stacking, from an interval partition of overlapping
events; and < … > alternation when the cycles differ. What it does not search is listed in
dev_docs/PAT_CONDENSE.md and pinned as tests, so the list cannot go stale on its own.
It is bounded, and it says so. A structural search over this grammar does not terminate if you
let it enumerate freely, so the range DP keeps PAT_CONDENSE_BEAM candidates per span, stops
proposing after PAT_CONDENSE_NODES spans, refuses a grid finer than PAT_CONDENSE_MAX_SLOTS
steps, and calls the oracle at most PAT_CONDENSE_ORACLE_CALLS times. Every one of those bounds is
reported on the result, so a caller can tell a genuine "there is nothing shorter" from a search that
ran out of room.
src/jvmMain/kotlin/afield/audio/synth/PatCondense.kt — read the source
Used in: Patterns: writing rhythm as a function of time
Top-level functions
| Declaration | Summary |
|---|---|
fun patCondense(source: String, cycles: Int? = null): PatCondensation |
Condense a mini-notation string: parse it, then find the shortest string that plays the same thing. |
fun patCondense(mask: List<Boolean>, on: String = "x"): PatCondensation |
Condense a boolean step mask, played with on on each true step. patCondense(bjorklund(3,8)) is x(3,8). |
fun PatPattern<String>.condense(cycles: Int? = null): PatCondensation |
Condense a live pattern. See patCondense for what cycles does. |
fun patCondense(transcription: PatTranscription): PatCondensation |
Close the transcription loop structurally. |
fun afield.document.graph.Box.showPatCondense(vararg sources: String, channel: String = "main"): List<PatCondensation> |
Draw each of sources against its own condensation in this box's output, and return what was found. |
fun patCondense(pattern: PatPattern<String>, cycles: Int? = null, fallback: String? = null): PatCondensation |
The search proper. |
Top-level values
| Declaration | Summary |
|---|---|
const val PAT_CONDENSE_BEAM: Int |
Candidates kept per span by the range DP. Raising it widens the search and slows it. |
const val PAT_CONDENSE_NODES: Int |
Spans the DP will expand before it falls back to the flat spelling for everything else. |
const val PAT_CONDENSE_MAX_SLOTS: Int |
Finest step grid patCondense will fit a cycle onto. Beyond this it refuses rather than guesses. |
const val PAT_CONDENSE_ORACLE_CALLS: Int |
How many candidates are re-parsed and re-rendered before the flat floor is taken. |
const val PAT_CONDENSE_MAX_PERIOD: Int |
Cycles patCondense will look at when asked to find a target's period for itself. |
PatCondensation
What patCondense found.
| Declaration | Summary |
|---|---|
val notation: String |
|
val flat: String |
|
val cycles: Int |
|
val exact: Boolean |
|
val structure: List<String> |
|
val refusal: String? |
|
val oracleCalls: Int |
|
val spansExpanded: Int |
|
val bounded: Boolean |
|
val compression: Double |
notation length over flat length. Below 1 is a win; 1 means the rhythm has no structure to find. |
val pattern: PatPattern<String> |
The condensed string, parsed. Identical to the target's pattern when exact. |
val limits: String |
The bounds this search ran under, for a caller comparing two results. |
fun describe(): String |
|
override fun toString(): String |
PatFunctions.kt
Bridge from pat(...) into scalar functions (Functions.Fn).
Three surfaces, in increasing order of how late the numbers arrive:
-
String atoms:
val fn = pat("g:(0,0.12) tent:(1,0.08,0.8) adsr:(0.6,0.01,0.08,0.4,0.2)") .asFn(cps = 2.0, range = 0.9)
-
Typed atoms with late-bound values:
val fn = pat("a b c") { atom -> when (atom) { "a" -> gaussianAtom(sigma = 0.08) { x -> kotlin.math.sin(local(x) * 12.0) } "b" -> gaussianAtom(sigma = 0.12) { x -> bipolarRandom() * (1.0 - normalized(x)) } else -> adsrAtom(attack = 0.01, decay = 0.08, sustain = 0.4, release = 0.2) { x -> 0.5 + 0.5 * noise(x, frequency = 18.0) } } }.asFn(cps = 2.0, maxLookAhead = 0.5)
The evaluator keeps a rolling cache of decoded atoms and only unrolls the source pattern far enough ahead to cover the maximum possible pre-support of future atoms. The current string forms are:
g:(value,sigma[,weight])tent:(value,radius[,weight])box:(value,radius[,weight])wendland:(value,radius[,weight])spline:(value,radius[,weight])env:(value,attack,release[,weight])-
adsr:(value,attack,decay,sustain,release[,weight]) -
String atoms with
%bindingplaceholders resolved at event-decode time:var t = 0.0 val fn = pat("g:(%x,0.2) tent:(0.3,0.08,0.8) adsr:(%y,0.01,0.08,0.4,0.2)") .asFn(cps = 2.0, range = 0.9) { binding -> when (binding) { "x" -> Math.random() "y" -> Math.sin(t) else -> 0.0 } }
Any numeric position in a string atom can hold %name instead of a literal.
The resolver is called at event-decode time (when the evaluator unrolls
events), so each cycle gets a fresh snapshot of the bound values.
If no explicit resolver is given, %name bindings are resolved from the
current box's KotlinInterface vars table (Box.context → ki.context.vars).
Any Number-typed var is coerced to Double; non-numeric or missing vars
resolve to 0.0.
The pat graph op
A string-atom pattern does not just evaluate as an Fn, it appears in the
Fn graph as a node of its own — op = "pat" — so a rhythm is a first-class
citizen of the rewrite/fit/evolve machinery rather than an opaque lambda that
happens to produce numbers. See scanPatAtomSlots for what gets hoisted.
strings["source"]is the pattern text, with the node's current numbers spliced back in, so a fitted gesture is a string you can paste into a box.- every numeric position of every string atom becomes a role-tagged child
(
atom0.sigma,atom1.attack, …), andcpsis one too. Those children areparamleaves, which is exactly whatextractParameters/fitGradienttreat as θ — so Adam tunes kernel widths, atom values and tempo directly. numberscarriesrangeandoutside, andbooleanscarriesstrict. These describe the evaluator rather than the gesture, so they are kept out of θ: a gradient has no business wandering into a cache bound.
A parameter child is read at each atom's own onset rather than once at build time, so grafting a real signal into a role (rather than leaving the constant leaf there) gives a kernel width that drifts across the piece.
Not every pattern can carry the node. The ones that cannot fall back to an
opaque Fn, which evaluates identically and simply cannot be rewritten:
- the typed-atom and custom-
atomDecodersurfaces (2. above) hold user lambdas, and a lambda cannot describe how to rebuild itself; - a
%bindingin a numeric position resolves against live box state at decode time, so the result is not a function ofxalone.fitGradientsamples non-monotonically and would be fitting a moving target, so a source that really uses a binding stays opaque. A binding resolver that the text never calls on is harmless, and does not cost the node.
src/jvmMain/kotlin/afield/audio/synth/PatFunctions.kt — read the source
Top-level functions
PatFunctionAtom
| Declaration | Summary |
|---|---|
val start: Double |
|
val end: Double |
|
val valueAt: (x: Double) -> Double |
|
val weightAt: (x: Double) -> Double |
|
fun contains(x: Double): Boolean |
|
fun safeWeightAt(x: Double): Double |
|
fun safeValueAt(x: Double): Double |
PatFunctionAtomScope
PatFunctionAtomSpec
fun interface PatFunctionAtomSpec
| Declaration | Summary |
|---|---|
fun materialize(event: PatScheduledEvent<*>, maxLookAhead: Double): PatFunctionAtom |
PatKolakoski.kt
Kolakoski rhythms: a bar whose own run lengths are written into it.
euclid(3, 8) asks for the most even spread of three onsets over eight slots, and answers with a
density and a period you chose in advance. A Kolakoski rhythm answers a different question — what
does a bar look like when the lengths of its runs are the bar itself? — and keeps answering it,
one step at a time, for as many steps as you ask for.
The Kolakoski word over {1, 2} begins
1 2 2 1 1 2 1 2 2 1 2 2 1 1 2 1 1 2 2 1 …
and it is the only sequence over {1, 2} starting with 1 that is its own run-length encoding: one
1, two 2s, two 1s, one 2, one 1, two 2s, … — reading the runs off gives 1, 2, 2, 1, 1, 2, … back.
kolakoskiWord generates it; KolakoskiReading is the choice of how to hear it.
kolakoski(16) // x..xx.x..x..xx.x — runs, the self-describing one
kolakoski(16, KolakoskiReading.GAPS) // xx.x.xxx.xx.x.xx — gaps between onsets
RUNS reads the terms as alternating on- and off-runs, starting with an on-run, so the rhythm's
own run lengths spell out the word that made it. GAPS reads each term as the distance to the
next onset, the way a Euclidean rhythm's onsets sit on a step grid. Every step is then either an
onset or a single rest, which fills in the long gaps the run-length reading leaves: eleven onsets
in sixteen steps against the other reading's eight.
Both readings are prefix-stable: the first sixteen steps of kolakoski(32) are exactly
kolakoski(16). Turning the step count up extends the figure instead of re-placing it, so the
step count is a dial you can turn without the hits you already have jumping around. euclid
cannot do that, because the step count is part of the question it answers: euclid(3, 8) is
x..x..x., and the first eight steps of euclid(3, 16) are x....x... PatKolakoskiTest
asserts both halves of that contrast.
The alphabet is a parameter. kolakoski(16, a = 2, b = 3) is the self-describing word over
{2, 3} — 2 2 3 3 2 2 2 3 3 3 2 2 … — which has no run shorter than two, so that figure never
leaves a lone hit or a lone rest: the same self-description, in bigger blocks.
src/jvmMain/kotlin/afield/audio/synth/PatKolakoski.kt — read the source
Used in: Patterns: writing rhythm as a function of time, Agent transcript gallery
Top-level functions
KolakoskiReading
How a Kolakoski word is read as a rhythm. See the file overview for the two figures.
Constants: RUNS, GAPS.
PatMiniNotation.kt
The mini-notation catalogue: the forms a pat("…") string can take, one entry each, with
the piano roll and the audio clip the documentation publishes under it.
A table of prose can say that bd*4 is four times as fast, but it cannot show that
{bd hh cp}%8 phases against the bar, or let you hear that bd _ _ sd and bd@3 sd are the
same rhythm. Each entry here renders a roll and a clip, so a reader can look at the notation and
listen to it instead of taking a gloss on trust.
One list, four consumers
EXAMPLES is the single source of truth. The same list produces the committed SVG rolls, the
committed .wav clips, the checks below, and, through markdownSection, the page's own
markdown. So an example cannot appear in the prose without a figure, carry a roll that disagrees
with its audio, or drift out of the order the page teaches it in.
PatMiniNotationDocsGuardTest.the page's mini-notation section is what the catalogue generates
compares the committed region of pattern_language.md against a fresh render and fails on any
difference, so the page cannot be hand-edited back out of step. Adding a form to the notation
means appending one Example.
What each entry is checked against
As a FIELD_TEST (synth.pat.mini) every entry is rendered to a temp dir and asserted, writing no
committed assets:
- it parses, draws at least one event, and draws nothing outside its own plotted window;
- the SVG holds exactly one rectangle per event plus one backdrop per lane plus one page background, so the figure cannot invent or drop a note;
- where an entry declares a longhand equivalent, as
bd ! ! sddoes forbd!3 sd, the two are compared arc for arc and value for value across every cycle, which is the one claim in this file a reader cannot check by eye; - the clip renders, through the pinned Dirt-Samples one-shots, to a RIFF/WAVE file, non-silent and all-finite; nothing is heard that the pattern did not schedule, and the level rises at every time it strikes;
checkAtomColorClaimholds the instrument colours to the voice in both directions: two tokens drawn in different colours must get different sounds out of the drum map, and two tokens sharing a colour must share a voice, so the roll never draws a difference the ear cannot find;- and the entries that make a specific musical claim carry their own check to prove it: that
?is deterministic, that<>changes per cycle, that%8phases.
Pass an output dir as args[0] (or set PAT_MINI_DIR) to regenerate the committed captures
under public/docs/assets/pattern_language/mini/. --markdown prints the page section instead.
src/jvmMain/kotlin/afield/audio/synth/PatMiniNotation.kt — read the source
Used in: Patterns: writing rhythm as a function of time
Top-level functions
| Declaration | Summary |
|---|---|
fun afield.document.graph.Box.showPatMini(id: String, channel: String = "main") |
Renders one PatMiniNotation example's compact roll into this box's output as inline SVG. |
PatMiniNotation
The mini-notation catalogue itself: the example groups, the page-markdown generator, and the FIELD_TEST entry point that renders and checks every entry.
| Declaration | Summary |
|---|---|
const val CPS |
Cycles per second for every clip. Percussive material, so the same 2 the other scenes use. |
const val MIN_STRIKE_RISE |
How much louder, at minimum, the window after a strike must be than the window before it. |
class Example(val id: String, val source: String, val gloss: String, val equivalent: String? = null, val cycles: Int = 2, val note: String? = null, val verify: (List<PatEvent<String>>) -> Unit = {}) |
One form of the notation, and everything published about it. |
class Group(val title: String, val blurb: String, val examples: List<Example>) |
One group of the catalogue — the page's ### headings, and the order it teaches in. |
val GROUPS: List<Group> |
|
val EXAMPLES: List<Example> |
Every example, in the order the page teaches them. |
fun onsetTimes(events: List<PatEvent<String>>): List<Double> |
The distinct times this pattern strikes, in order — what a listener would count. |
fun svgFor(id: String): String |
Resolves and draws example id's compact roll. |
fun renderWav(name: String, outPath: String) |
Offline-renders example id and writes a stereo 16-bit PCM WAV — the docs fence's entry point. |
fun render(example: Example): PatSoundDocs.Stereo |
The audio for one example at CPS, through the pinned Dirt-Samples one-shots. |
fun checkAtomColorClaim() |
Check PatPianoRollGallery.ATOM_COLORS against the voices patDrumHz gives the same tokens. |
fun markdownSection(assetDir: String = "assets/pattern_language/mini"): String |
The page's own mini-notation section, generated from GROUPS. |
fun cardMarkdown(example: Example, assetDir: String = "assets/pattern_language/mini"): String |
One card: the notation, its roll, its clip, and its gloss. |
fun strikeRise(mono: FloatArray, atCycles: Double): Double |
RMS over the analysis window after atCycles divided by RMS over the window before it. |
fun main(args: Array<String>) |
PatOnsetDetection.kt
Where a rendered pattern's notes actually start, measured from the audio.
One detector, so that two renderers can be measured with the same instrument. Field renders a
pattern offline through patRenderStereo; Sonic Pi renders it through SuperCollider after
SonicPiPatterns lowers it. Either can be checked against the pattern's exact rational schedule,
and the comparison only means anything if the measuring device is shared, since two detectors tuned
separately would each agree with their own renderer.
The signal processing is ordinary: an energy envelope, half-wave rectified flux, a local-maximum test and a refractory gap. That is adequate because the question is when rather than what, and these are bare percussive trains with nothing else in the mix.
Two details decide whether the answer means anything.
Resolution. A flux peak in a 256-sample window can only say which frame something happened
in, and a measurement whose resolution is coarser than the error it is looking for reports zero
whatever the truth is. So every coarse hit is walked back to the first sample that crosses a
fraction of the local peak, and patDetectOnsetSamples returns samples rather than frames.
The refractory gap, applied after refinement. bd_haus has a secondary energy rise 129 ms into
its decay. A first-past-the-post refractory on the coarse stage reports that shoulder and
suppresses the real attack behind it, which reads as a timing error rather than the detection error
it is. So the gap is the pattern's own smallest interval, and deduplication happens after the
onsets are exact.
src/jvmMain/kotlin/afield/audio/synth/PatOnsetDetection.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun patEnergyEnvelope(samples: FloatArray, hop: Int = PAT_ONSET_HOP, window: Int = PAT_ONSET_WINDOW): DoubleArray |
RMS per hop — a plain energy envelope, which is all a percussive train needs. |
fun patDetectOnsetSamples(samples: FloatArray, sampleRate: Int, minGapSeconds: Double, envelope: DoubleArray = patEnergyEnvelope(samples), sensitivity: Double = 0.30): List<Int> |
Where each note starts, to the sample. |
fun patMatchOnsets(observedSeconds: List<Double>, scheduledSeconds: List<Double>, tolerance: Double): PatOnsetMatch |
Compare an onset train against the schedule it should have realised. |
Top-level values
| Declaration | Summary |
|---|---|
const val PAT_ONSET_HOP: Int |
Analysis hop, in samples. 32 at 48 kHz is 0.67 ms — finer than anything being asserted. |
const val PAT_ONSET_WINDOW: Int |
Analysis window, in samples. |
PatOnsetMatch
How an observed onset train compared with the schedule it was supposed to realise.
| Declaration | Summary |
|---|---|
val observed: Int |
|
val scheduled: Int |
|
val matched: Int |
|
val worstError: Double |
Worst |observed − scheduled| in seconds, after the constant offset is removed. |
val meanError: Double |
|
val recall: Double |
|
val offset: Double |
The constant lag removed before measuring — reported, never judged. |
fun describe(label: String): String |
PatPatternReturnValue.kt
The step between pat("bd sd").respond { … } and a pattern that is actually playing: the
return-value handler that recognises a PatResponseSpec coming back from a box and runs it.
respond is a builder. It captures the pattern, the cycles-per-second, the clock and the
callback into a spec and returns, having started nothing. This file is the other half, and the
arrangement is what makes a responder behave like every other launchable value in Field: it runs
because the box's last expression is one, it can be bundled with +, it can be wrapped in
.onBeat, and stopping the box stops it.
The loop it runs is short, and three of its rules shape what a callback sees:
- Events that share a start time go out together. The iterator is drained into a bucket of events within a nanosecond of each other, the wait happens once, and then the whole bucket is dispatched. A chord is a chord rather than a queue of near-simultaneous notes.
- Events already in the past are dropped, not played late. An event whose start has gone by is skipped, which is how a pattern joined mid-flight, or one whose callback overran, catches up instead of falling further behind.
aheadshifts the callback, not the schedule. The wait targetsstart - ahead, so a callback that has to buy latency somewhere else, a sample player with its own scheduling queue, gets the event early and still knows its real time fromevent.start.
Dispatch is on the run loop unless dispatchOnMain = false, so a callback may touch boxes,
properties and graphics without hopping threads itself.
src/jvmMain/kotlin/afield/audio/synth/PatPatternReturnValue.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun registerPatPatternReturnValue() |
Teaches box execution about PatResponseSpec. Called once during return-value registration, not from box code. |
PatPianoRollGallery.kt
Headless piano-roll gallery for the pattern language, drawing the operators defined across
TidalPatterns.kt, PatSignals.kt, PatAlgebra.kt and PatComposition.kt.
Output is SVG, not raster. A piano roll is line art (rectangles, gridlines, a parameter polyline), so it is drawn as vector geometry, built here as plain strings. That also makes it diffable: a changed operator shows up as a changed rectangle in the file rather than as a changed PNG blob. There is no AWT, no browser, no GPU, no Field window.
Every rectangle is a scheduled event. Each lane queries its pattern over the roll's cycles
through PatPattern.events(...), and nothing is hand-placed; the only parts chosen by hand are
the colours and the mapping from cycle time to pixels.
How a roll is read
A roll is a stack of horizontal lanes drawn at one shared time scale, so reading down a column
compares the same instant across all of them. Most rolls are built as a source lane with its
transforms underneath: pat("a b c d") on top, then rev(), euclid(3,8), iter(4) and the
rest below it. Where a form is shorthand for a longhand spelling, the two are lanes of the same
roll and the check compares them event for event.
What each roll is checked against
As a FIELD_TEST (synth.pat.gallery) every roll is rendered to a temp dir and asserted: each
lane draws at least one event, no event falls outside the window the roll plots, and the SVG
holds exactly one rectangle per event plus one backdrop per lane plus one page background, so
the figure can neither invent a note nor drop one. Each roll then runs its own verify over the
events it actually drew, which is where the musical claim lives: that rev reverses, that
euclid(3,8) puts three onsets on the eighth grid with gaps as even as 8/3 allows, that
iter(4) rotates by a step per cycle, that shuffle(4) is a permutation every cycle, and that
a continuous parameter takes the value of its curve at each note's own midpoint. It writes no
committed assets during the test. Pass an output dir as args[0] (or set PAT_ROLL_DIR) to
regenerate the committed captures under dev_docs/assets/pat_gallery/.
Add a roll by appending one Roll to ROLLS.
src/jvmMain/kotlin/afield/audio/synth/PatPianoRollGallery.kt — read the source
Used in: Patterns: writing rhythm as a function of time
Top-level functions
| Declaration | Summary |
|---|---|
fun afield.document.graph.Box.showPatRoll(name: String, channel: String = "main") |
Renders a PatPianoRollGallery roll into this box's output as an inline SVG. |
fun afield.document.graph.Box.showPatRollOf(vararg lanes: Pair<String, PatPattern<String>>, title: String = "pattern", subtitle: String = "", cycles: Int = 2, paramKey: String? = null, pitched: Boolean = false, channel: String = "main") |
Rolls arbitrary patterns into this box's output — one lane per label to pattern pair. |
PatPianoRollGallery
The catalogue of piano rolls, the SVG renderer behind them, and the FIELD_TEST entry point.
PatPitchDetection.kt
What pitch a rendered pattern is actually sounding, measured from the audio.
PatOnsetDetection answers when. This answers what, and the two are not the same question with
a different threshold. An energy detector reads the amplitude envelope, so it can only see a note
that begins with an attack; a pitch detector reads periodicity, so it can see a note that begins
while the previous one is still sounding at full level. That difference is not a nicety. patVoice
defaults to sustain = 1.0 (PatSoundDocs.kt:747), which means the default render of a gapless
melodic sequence is one continuous tone with exactly one attack in it, and an energy detector cannot
segment it however it is tuned — not because it is badly configured but because the evidence it
consumes is not in the signal. patDetectPitchChangeSamples is the complement, and
PatPitchTranscriptionTest measures the pair on the same render so the claim is a number.
The estimator is YIN, the cumulative-mean-normalised difference function, computed directly in the
time domain. Spectral.kt:26 has a real FFT and an autocorrelation could be had from it for less
arithmetic, but YIN's threshold and its aperiodicity confidence are both defined on the difference
function, so keeping those constants over an autocorrelation would be taking the numbers without
the derivation they come from. At these corpus sizes the direct form costs seconds.
Three things here decide what the detector can resolve.
The window is derived from PAT_PITCH_MIN_HZ, not fixed. YIN needs a frame at least twice the
longest period it is asked to consider, so the lowest pitch admitted sets the frame length, and the
frame length sets the time resolution. A detector told it might hear a 65 Hz note is looking through
a 50 ms window whatever it is actually listening to. So minHz/maxHz are the same kind of
parameter as patDetectOnsetSamples' minGapSeconds: the detector is told what register it is
allowed to resolve, and buys sharpness with that promise. Telling it the truth about a treble line
shortens its window fourfold; guessing wide costs time resolution and gains nothing, which is why
the register is an argument here rather than a constant.
A frame's pitch belongs to the frame's centre, not its start. A frame spans window + tauMax
samples, so attributing its estimate to the first of them would report a note as starting up to 50 ms
before it did. PatF0Frame.centerSample is what every consumer here uses, and a detected pitch
change is then localised inside the frame it was noticed in by re-analysing with a window sized to
the new note's period — the same walk-back move patDetectOnsetSamples makes, for the same reason.
Timbre is not polyphony, and the detector has to be able to tell them apart. A sawtooth is a
stack of partials and a triad is a stack of partials; refusing everything harmonically rich would
make the refusal worthless. patHarmonicResidual measures the fraction of strong spectral peak
energy that does not sit near an integer multiple of the detected f0, which is near zero for any
single note of any timbre and large for a chord. A monophonic detector that reports it cannot name
a chord is telling the truth; one that picks the loudest note is not.
src/jvmMain/kotlin/afield/audio/synth/PatPitchDetection.kt — read the source
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val PAT_PITCH_HOP: Int |
Analysis hop, in samples. 128 at 44.1 kHz is 2.9 ms. |
const val PAT_PITCH_MIN_HZ: Double |
Lowest fundamental admitted, in Hz. 65 Hz is C2 in Tidal's spelling (MIDI 36). |
const val PAT_PITCH_MAX_HZ: Double |
Highest fundamental admitted, in Hz. 2200 Hz is a little above C7. |
const val PAT_PITCH_YIN_THRESHOLD: Double |
YIN's absolute threshold on the normalised difference function: the first dip below this is taken as the period, in preference to a deeper dip at a longer lag. That preference is what keeps a harmonically rich note from being reported an octave low. |
const val PAT_PITCH_PERIODICITY: Double |
Minimum aperiodicity-complement for a frame to be called voiced. |
const val PAT_PITCH_LEVEL_FLOOR: Double |
Fraction of the 95th-percentile frame RMS below which a frame is called unvoiced whatever its periodicity. Silence is extremely periodic if you divide zero by zero carefully enough. |
const val PAT_POLYPHONY_RESIDUAL: Double |
Above this harmonic residual a span is reported as more than one note rather than named. |
const val PAT_PITCH_MIN_SEMITONES: Double |
Semitones of change between adjacent voiced frames that counts as a new note. |
PatF0Frame
One frame's worth of pitch evidence.
PatDetectedNote
One recovered note.
| Declaration | Summary |
|---|---|
val startSample: Int |
|
val endSample: Int |
|
val sampleRate: Int |
|
val midi: Int |
|
val centsOffset: Double |
|
val confidence: Double |
|
val voicedFrames: Int |
|
val harmonicResidual: Double |
|
val startSeconds: Double |
|
val endSeconds: Double |
|
val measuredMidi: Double |
The pitch actually measured, before rounding: midi plus centsOffset. |
val notOneNote: Boolean |
True when this span is not one note at the pitch named for it. |
override fun toString(): String |
PatPitchVerdict
What a buffer is, when a monophonic detector could not name notes in it.
Constants: PITCHED, POLYPHONIC, UNPITCHED, UNRESOLVED, SILENT.
PatPitchDiagnosis
Why nothing could be named, distinguished by spectral sparsity.
| Declaration | Summary |
|---|---|
val verdict: PatPitchVerdict |
|
val loudFrames: Int |
|
val loudUnvoicedFrames: Int |
|
val spectralPeaks: Int |
|
fun describe(): String |
PatSignals.kt
Continuous (signal) patterns, and the discretisers that turn them back into events.
PatPattern in TidalPatterns.kt is an event stream: query an arc, get back a list of
things that happened inside it. A signal is the limiting case of that: exactly one event per
query, spanning the whole arc, whose value is a function of time. It needs nothing new in the type
system, because a signal is a PatPattern<Double> that always answers, so every combinator in the
language already applies to it.
Read the surface from the outside in:
-
A continuous value:
val lfo = sine() // 0..1, one cycle per pattern cycle val swept = saw().range(200, 4000)
-
Sample it — nothing plays a continuous pattern directly, you discretise it:
val steps = sine().segment(8) // exactly 8 events per cycle val grid = struct(euclid(3, 8), pat("bd"))
-
Or hand it to the parameter algebra in PatAlgebra.kt, which samples it for you:
pat("bd sd hh sd") with gain(sine().range(0.2, 1.0))
The sampling convention
A continuous pattern queried with arc a returns one event spanning a whose value is
f(mid(a)), and mid(a) is the exact rational midpoint (a.start + a.end) / 2 — the
conversion to Double happens only inside f. Three reasons this is the right convention:
- it is what Tidal does, so ear-knowledge transfers;
- the midpoint is the only choice under which
segment(n)samples each segment at that segment's own centre, which makessine().segment(4)symmetric about the cycle and stable underrev()— an asymmetric convention (start, or end) breaks both; - it is total. Every arc, however small, has a value, so a continuous pattern can never be "missed" by a query the way a zero-width event could be.
segment(n) does not sample at the query arc's midpoint. It builds the 1/n grid itself, samples
the source over each whole grid cell, and clips to the query arc only afterwards, so the values
you observe do not depend on how the caller chopped up the arc. PatSignalsTest asserts that.
src/jvmMain/kotlin/afield/audio/synth/PatSignals.kt — read the source
Used in: A pattern as a function generator, Patterns: writing rhythm as a function of time, Petri nets
Top-level functions
| Declaration | Summary |
|---|---|
fun patArcMidpoint(arc: PatArc): PatQ |
The exact rational midpoint of arc. Used as the sampling instant for every signal. |
fun signalOf(sample: (exact: PatQ, t: Double) -> Double): PatPattern<Double> |
Builds a continuous pattern from a function of cycle time. |
fun sig(sample: (t: Double) -> Double): PatPattern<Double> |
Builds a continuous pattern from a plain function of cycle time. sig { t -> t * t }. |
fun constant(value: Number): PatPattern<Double> |
Constant continuous signal — the identity of the numeric operator family. |
fun sine(): PatPattern<Double> |
0.5 + 0.5·sin(2πt) — one full cycle of a sine per pattern cycle, in 0..1. |
fun cosine(): PatPattern<Double> |
0.5 + 0.5·cos(2πt) — sine a quarter cycle early, in 0..1. |
fun saw(): PatPattern<Double> |
A rising ramp 0 → 1 once per cycle. |
fun isaw(): PatPattern<Double> |
A falling ramp 1 → 0 once per cycle. |
fun tri(): PatPattern<Double> |
Up then down: 0 → 1 → 0 once per cycle. |
fun square(): PatPattern<Double> |
0 for the first half of each cycle, 1 for the second — exactly, on the rational midpoint. |
fun envL(): PatPattern<Double> |
A one-shot linear rise 0 → 1 across the first cycle, then held at 1. |
fun envLR(): PatPattern<Double> |
A one-shot linear fall 1 → 0 across the first cycle, then held at 0. |
fun rand(salt: Int = 0): PatPattern<Double> |
Deterministic continuous randomness in 0..1, hashed from the exact rational instant. |
fun irand(n: Int, salt: Int = 0): PatPattern<Double> |
rand quantised to 0 until n (as a Double, so it composes with the numeric algebra). |
fun perlin(salt: Int = 0): PatPattern<Double> |
Smooth value noise: rand sampled at integer cycles, smoothstep-interpolated between them. Unlike rand it is continuous, so perlin().segment(64) reads as a slow drift rather than hash. |
fun run(n: Int): PatPattern<Double> |
0 1 2 … n-1 as n events per cycle, exactly on the 1/n grid. |
fun grid(n: Int): PatPattern<Boolean> |
The 1/n grid as a boolean pattern — the structure source for struct and segment. |
fun <A> PatPattern<A>.segment(n: Int): PatPattern<A> |
Slices each cycle into n equal cells and takes one value per cell from this pattern. |
fun <A> PatPattern<A>.discretise(n: Int): PatPattern<A> |
Alias for segment, for readers coming from Tidal's discretise. |
fun <A> struct(structure: PatPattern<Boolean>, values: PatPattern<A>): PatPattern<A> |
Takes rhythm from structure (its true events) and values from values. |
fun bools(source: String): PatPattern<Boolean> |
Boolean structure from mini-notation t/f (also true/false, 1/0, x/~). |
fun PatPattern<Double>.range(lo: Number, hi: Number): PatPattern<Double> |
Maps a 0..1 pattern onto [lo, hi]. Values outside 0..1 extrapolate rather than clamp. |
fun PatPattern<Double>.rangex(lo: Number, hi: Number): PatPattern<Double> |
Maps a 0..1 pattern onto [lo, hi] exponentially — the right shape for a filter cutoff. |
fun PatPattern<Double>.bipolar(): PatPattern<Double> |
Re-centres a 0..1 pattern onto -1..1. |
fun PatPattern<Double>.quantise(step: Number): PatPattern<Double> |
Rounds values to the nearest multiple of step. sine().quantise(0.25) gives 5 levels. |
fun PatPattern<Double>.rounded(): PatPattern<Int> |
Rounds values to whole numbers — the bridge from a signal into MIDI pitch or a sample index. |
fun PatPattern<String>.nums(): PatPattern<Double> |
Reads mini-notation atoms as numbers, dropping anything unparseable. |
fun PatPattern<Double>.texts(): PatPattern<String> |
Renders numeric atoms back to strings, so a numeric pattern can drive notes / with. |
fun patFormatNumber(v: Double): String |
Canonical, locale-free short form for a parameter value. 1.0 → 1, 0.25 → 0.25. |
PatSoundDocs.kt
Headless offline sound renderer for the pattern language: the audible companion to
PatPianoRollGallery's piano rolls, in the same harness shape as DoughDspSoundDocs.
Every scene schedules a pattern and runs its events through patRenderStereo with a
PatVoiceSpec — one oscillator, one one-pole lowpass, one exponential-decay envelope — reading
each event's own gain, pan, cutoff and sustain annotations. There is no realtime engine,
no audio device, no GPU and no Field window, and the render is deterministic, so a committed
.wav regenerates byte-stably.
A before and an after in one file
Several scenes splice two versions of one pattern together with whenCycle({ it >= 2 }): the
opening cycles play it without the operator under discussion and the closing cycles play the
same pattern with it, so the difference arrives in a single listen. Those scenes check their two
halves against each other, requiring the modulated half's level to vary by some multiple of the
flat half's, rather than against a remembered number, so retuning the voice leaves the check
meaningful.
What each render is checked against
As a FIELD_TEST (synth.pat.sound) every scene is rendered to a temp dir and asserted: a
RIFF/WAVE header whose declared channel count, sample rate and data size agree with the bytes on
disk; a duration within a tenth of a second of what the scene's own cycle count and tempo imply;
peak and RMS inside bounds that catch silence and runaway level; and then each scene's own
verify, which is where the musical claim lives. Those range from counting onsets to checking
that the filter sweep brightens, that the voice-led chords sound the notes the pattern says, and
that jux decorrelates the two channels. It writes no committed assets during the test. Pass an
output dir as args[0] (or set PAT_SOUND_DIR) to regenerate the committed captures.
Add a sound by appending one Scene to SCENES.
src/jvmMain/kotlin/afield/audio/synth/PatSoundDocs.kt — read the source
Used in: A Drawn Score, Heard, Sound: from a pattern to something you can hear, A Drawn Morph, Heard
Top-level functions
PatSoundDocs
The catalogue of rendered pattern scenes, and the FIELD_TEST entry point that renders them.
PatWave
Oscillator shapes patVoice can use. NOISE is a deterministic hash, never Random.
Constants: SINE, SAW, SQUARE, TRIANGLE, NOISE.
PatVoiceSpec
A tiny deterministic synth voice: one oscillator, one exponential-decay envelope, one one-pole lowpass.
| Declaration | Summary |
|---|---|
val wave: PatWave |
|
val baseHz: Double |
|
val decay: Double |
|
val sustain: Double |
|
val attack: Double |
PatSpatial.kt
A pattern placed in space: pat(...) -> DoughEngine -> Steam Audio HRTF, in realtime.
Three subsystems that do not know about each other, joined by two coercions and one thread:
| what it is | what this file asks of it | |
|---|---|---|
PatPattern |
Tidal-style pattern language, polyphonic by construction | query a cycle, read the annotations |
DoughEngine |
the Strudel/superdough wasm synth, 32 voices | one engine per spatial source |
afield.audio.spatial.steamaudio |
Steam Audio 4.8.1 over FFM | direct path plus HRTF, per source, per block |
The tag is the spatial parameter
A pattern's named parameters ride on PatEvent.annotations, and with writes any key at all into
that map, so the pattern language needed no extension to describe where a sound is. Six keys are
read here and every other key is forwarded to Dough untouched:
| tag | means | default |
|---|---|---|
spat |
which spatial source -- i.e. which Dough engine -- the event plays on | "0" |
az |
azimuth in degrees; 0 ahead, +90 to the right | 0 |
elev |
elevation in degrees; +90 overhead | 0 |
dist |
distance in metres | 1.5 |
occl |
occlusion, 0 clear to 1 fully blocked | 0 |
air |
1 to apply air absorption over distance, 0 for none | 1 |
patSpatial(
pat("bd ~ sd ~, hh*8").with(
paramOf("spat", pat("<kit hats>")),
param("az", sine().range(-70.0, 70.0)),
param("dist", "1 3 1 6"),
),
cps = 2.0,
)
Because az can take a continuous pattern, sine() sampled per event is a source that moves
between notes; and because the placement is held on the source rather than the note, the same
pattern's next event on that spat group moves the whole group. The tags do not decorate the
sound, they aim it.
Why one Dough engine per source
Steam Audio spatialises a mono signal -- a direction is a property of a whole buffer.
DoughEngine mixes its 32 voices to stereo and MAX_ORBITS is 1, so there is no way to pull two
separable signals out of one engine. Hence one engine per spat group, which is also why spat
exists at all rather than every event getting its own position.
Two consequences:
- A pattern's
panis overridden. Every event is sent withpan/0, which puts all of that engine's energy in its left channel, and that channel is the mono source signal. Panning is lossless this way (no0.5*(L+R)summing) butpanas a musical parameter is gone:azreplaces it, and it is a better parameter. - Note onsets quantise to a Dough block, 128 frames or 2.9 ms at 44.1 kHz. Dough takes an
event through
evaluate()and applies it at the nextdsp(), so unlikePatSynthBank-- which is sample-exact, and proves it by rendering bit-identically at four buffer sizes -- this path cannot place a note inside a block. That is a property of Dough's event API, not of the scheduler.
Why Dough is not on the audio thread
PatSynth puts the pattern query on a feeder thread because querying allocates, and keeps
synthesis on the audio thread because Field's own voices are a fixed amount of float arithmetic.
Neither half of that argument survives here. DoughEngine is GraalWasm: every block enters a
polyglot context, and the JIT and the collector get a say in how long that takes.
PatSpatialBudgetProbe measures it, idle and under load; the percentiles are in
dev_docs/PAT_SPATIAL_MEASUREMENTS.md. The median is flat -- the arithmetic of four wasm
engines and eight Steam Audio effects is 4% of a block either way, so nothing here needs
optimising. The tail moves by a factor of three with load the audio thread has no say over. A
path whose median fits twenty-five times over and which still misses one block in a hundred is
not a path to shave down; it is one to decouple.
So PatSpatialRoom renders on its own thread into PatSpatialRing, and the audio thread's entire
job is a bounded copy out of that ring. A ring holding more milliseconds than the worst stall
turns a dropout into a dip in PatSpatialRing.fill, and PatSpatialRing.underruns is the number
that says whether that held.
The one stall the ring cannot absorb is booting a DoughEngine, at 130-500 ms. That is what
PatSpatialRoom.prescanCycles exists for, and what PatSpatialRoom.lateSourceBoots counts when
a spat tag turns up too late to have been prescanned.
What limits the render thread
Not the arithmetic. Under heavy load PatSpatialEngineSmoke has recorded the render thread
missing most of a window's blocks while busy only a third of the elapsed time; that run is in
dev_docs/PAT_SPATIAL_MEASUREMENTS.md. A thread that is idle two thirds of the time and still
misses its deadline is not a thread that needs faster code; it is a thread that is not being
scheduled.
Two things follow, and both are in the code rather than in advice:
PatSpatialRoom.busyMsTotalagainst elapsed time separates the two ways of falling behind. Near 100% busy with underruns means the work does not fit and this design is wrong. Far below it means the machine is oversubscribed, andPatSpatialEngineSmokesays so instead of failing.- The ring is sized for the scheduler's stalls, not for this code's, which is why it defaults to 341 ms rather than the ~40 ms the arithmetic alone would justify.
Raising the render thread's priority was tried and reverted; the comment in
PatSpatialRoom.start records what it did instead of helping.
Where this is written up for a reader
public/docs/pat_spatial.md — the page that decides whether to use this, rather than how it
works. It carries the findings without the percentiles, plus the two things this file cannot
hold: PatSpatialSoundDocs' published clips of the mono source, and the argument for why the
spatialised output has no clip and cannot have one. It is also declared in SoundPathMap as the
pat-spatial path, which is what keeps that page and this file from drifting apart.
src/jvmMain/kotlin/afield/audio/synth/PatSpatial.kt — read the source
Used in: A pattern in space: pat → Dough → Steam Audio
Top-level functions
PatSpatialPlacement
Where a spatial source is, in Steam Audio's listener-relative frame. Metres and degrees.
| Declaration | Summary |
|---|---|
val azimuthDeg: Double |
Azimuth in degrees: 0 ahead, +90 to the listener's right. |
val elevationDeg: Double |
Elevation in degrees: 0 at ear height, +90 overhead. |
val distance: Double |
Distance in metres. Clamped to MIN_DISTANCE so a source cannot land inside the head. |
val occlusion: Double |
Occlusion, 0 clear to 1 fully blocked. |
val airAbsorption: Boolean |
Whether the direct path loses high frequencies over distance. |
fun direction(): FloatArray |
Unit direction in Steam Audio's convention: -Z ahead, +X right, +Y up. |
HeadPose
Where the listener's head is in the room, as position plus orientation quaternion.
| Declaration | Summary |
|---|---|
val x: Double |
|
val y: Double |
|
val z: Double |
|
val qx: Double |
|
val qy: Double |
|
val qz: Double |
|
val qw: Double |
|
val azimuthDeg: Double |
This pose's heading in degrees, positive to the right -- the quantity PatSpatialRoom.turnListener takes. Yaw alone does not describe a head that is also nodding or tilted; this is a readout, and the spatialisation uses the whole quaternion. |
PatSpatialEvent
One pattern event, resolved into a Dough event and a placement, stamped with its start frame.
| Declaration | Summary |
|---|---|
val startFrame: Long |
Absolute frame on PatSpatialRoom.framesRendered's clock. |
val sourceKey: String |
The spat tag: which source this plays on. |
val doughEvent: Map<String, Any?> |
The Dough event, already carrying pan/0. |
val placement: PatSpatialPlacement |
Where the source moves to when this event starts. |
PatSpatialRing
class PatSpatialRing(requestedFrames: Int)
A single-producer single-consumer ring of interleaved stereo frames.
| Declaration | Summary |
|---|---|
val capacityFrames: Int |
Frames of headroom. Rounded up to a power of two. |
val fill: Int |
Frames written but not yet consumed. |
val room: Int |
Frames the writer may add right now. |
var underruns: Long |
Frames the audio thread asked for and the ring could not supply. |
var minFill: Int |
Lowest fill the audio thread has ever seen, in frames. How close this came to a dropout. |
fun write(l: FloatArray, r: FloatArray, n: Int) |
Write n frames from l and r. Called only by the render thread. |
fun readInto(outL: FloatArray, outR: FloatArray, n: Int) |
Add n frames into outL and outR. Called only by the audio thread. |
PatSpatialSource
One spatial source: a Dough engine, and the Steam Audio effects that place its output.
| Declaration | Summary |
|---|---|
val key: String |
|
var notesPlayed: Long |
Notes started on this source. |
val placement: PatSpatialPlacement |
Where this source is right now, as the render thread last placed it. |
var activeVoices: Int |
Voices sounding in this source's engine, as the render thread last published them. |
var peakVoices: Int |
The most voices ever seen sounding at once on this source. A sampled chord shows up here. |
override fun close() |
|
override fun toString(): String |
PatSpatialRoom
The render thread: pattern in, spatialised stereo into a PatSpatialRing.
| Declaration | Summary |
|---|---|
val pattern: PatPattern<String> |
|
val cps: Double |
|
val sampleRate: Int |
|
val gain: Double |
|
val ring: PatSpatialRing |
|
val wasmPath: Path |
|
val maxSources: Int |
|
val targetFillFrames: Int |
Frames the render thread tries to keep in the ring. Its tolerance for its own stalls. |
val hrtfInterpolation: HrtfInterpolation |
|
val defaultSound: String |
|
val prescanCycles: Int |
Cycles scanned up front for spat tags, so their engines boot before any audio is due. |
val framesRendered: Long |
Frames the render thread has produced. The clock a PatSpatialEvent is stamped on. |
var cyclesQueried: Long |
Cycles of pattern queried so far. |
var ready: Boolean |
True once Steam Audio is up, every prescanned engine has booted, and the ring has filled. |
var failure: Throwable? |
Non-null if the render thread died; the reason it died. |
var lateSourceBoots: Int |
Engines booted after the ring was already feeding an audio thread. |
var worstBlockMs: Double |
Longest single block the render thread has taken, in milliseconds. |
var blocksRendered: Long |
Blocks rendered since the ring first filled. |
var slowBlocks: Long |
Blocks that took longer than 5 ms -- longer than the audio they produced. |
var stallBlocks: Long |
Blocks that took longer than 50 ms, which no reasonable ring depth absorbs repeatedly. |
var slowBlockMsTotal: Double |
Total milliseconds spent in blocks over 5 ms. How much lateness there is to absorb. |
val busyMsTotal: Double |
Milliseconds this thread spent doing its own work since the ring filled. |
var queryMsTotal: Double |
Where the render thread spends its time, split by stage, in milliseconds. |
var eventMsTotal: Double |
|
var synthMsTotal: Double |
|
var spatialiseMsTotal: Double |
|
val sources: List<PatSpatialSource> |
Sources that exist, in the order their spat tags were first seen. |
fun turnListener(azimuthDeg: Double) |
Turn the listener's head to azimuthDeg, from any thread. |
fun setListenerPose(pose: HeadPose) |
Move and turn the listener's head, from any thread. The render thread applies the newest pose it finds at the top of a block; poses offered faster than that are skipped, not queued. |
fun setListenerPoseAt(poseAt: ((frame: Long) -> HeadPose)?) |
Give the listener a pose that is a function of the render frame, or null to go back to setListenerPose. |
val listenerAzimuthDeg: Double |
The listener's heading in degrees, as the render thread last applied it. |
var listenerPose: HeadPose |
The whole pose the render thread last applied, position included. |
fun stop() |
|
val isRunning: Boolean |
|
override fun toString(): String |
PatSpatialBank
The audio thread's end of the chain: drain a PatSpatialRing into the output.
| Declaration | Summary |
|---|---|
val ring: PatSpatialRing |
|
val sampleRate: Int |
|
val masterGain: Double |
|
val frame: Long |
Frames the audio thread has pulled. |
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) |
|
override fun toString(): String |
PatSpatialLaunch
A spatialised pattern, as one thing a box can return.
| Declaration | Summary |
|---|---|
val bank: PatSpatialBank |
|
val room: PatSpatialRoom |
|
val tap: StereoScope |
A tap on this launch's own output, fed from the audio thread. |
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) |
|
val sources: List<PatSpatialSource> |
The spatial sources the pattern's spat tags have brought into being. |
fun turnListener(azimuthDeg: Double) |
Turn the listener's head. Positive is to the right. |
fun setListenerPose(pose: HeadPose) |
Move and turn the listener's head. See PatSpatialRoom.setListenerPose. |
fun setListenerPoseAt(poseAt: ((frame: Long) -> HeadPose)?) |
A pose per render frame, for a scripted head. See PatSpatialRoom.setListenerPoseAt. |
fun followHead(head: HeadPoseSource, hz: Double = HeadTrackedListener.DEFAULT_HZ): HeadTrackedListener |
Drive the listener from a tracked head -- an OpenXR headset, or anything else that reports a pose. The returned tracker is started; closing it stops following, and closing this launch does not. |
val underruns: Long |
Frames the audio thread asked for and the ring could not supply. Zero is the whole point. |
fun awaitReady(timeoutMs: Long = 20_000L): Boolean |
Block until the render thread has Steam Audio up and has filled the ring, or timeoutMs. |
fun advanceOffline(frames: Int) |
Pull frames through the bank and into tap, for when nothing else is pulling it. |
override fun close() |
|
override fun toString(): String |
PatSpatialBudgetProbe.kt
The patSpatial test that measures rather than asserts: Dough engines and Steam Audio effects in
a tight loop, with no room, no ring and no audio thread.
PatSpatialSmokeTest, HeadTrackedSpatialSmokeTest and PatSpatialEngineSmoke all render
through PatSpatialRoom and check the samples that came out. This one takes the arithmetic out
of that design and times it alone: DoughEngines driven straight, for one, two and four
sources, against the milliseconds of audio a single block holds.
It asserts nothing: the test entry that runs it checks the exit code and one marker line, so
every figure it prints is one observation on one machine rather than a contract.
dev_docs/PAT_SPATIAL_MEASUREMENTS.md keeps the last set alongside the method that produced it.
The run drops the first tenth of its blocks as warmup, splits the per-block cost into its Dough and Steam Audio halves, and reports the median, p99, p99.9 and the worst block, because those move differently under load.
Run it with tools/run_field_tests.py --id synth.pat-spatial.budget.
src/jvmMain/kotlin/afield/audio/synth/PatSpatialBudgetProbe.kt — read the source
PatSpatialBudgetProbe
Measures the cost of one DoughEngine per spatial source, against one audio block's deadline.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
PatSpatialEngineSmoke.kt
The delivery check on patSpatial: the only one of its tests that opens an audio device.
PatSpatialSmokeTest and HeadTrackedSpatialSmokeTest pull PatSpatialBank themselves, which
proves the audio is right and says nothing about whether it reaches an output. This registers the
launch with FieldAudioEngine and lets that engine's own thread drain PatSpatialRing for four
seconds.
Two outcomes end the run green having proved nothing, and telling them apart is why a silent
control patch is registered alongside the launch. outputActive false means no device opened.
outputActive true with the control patch never rendered means a device opened and is not
advancing: the engine refills its buffers only as OpenAL reports them processed, so a contended
device leaves it rendering no patch at all while still reporting an active output. Each case
names itself on stdout.
An underrun is not read as a failure on its own either. The run records the render thread's own busy time and the machine's load average, and accepts an underrun only when that thread was well short of saturating a core on a machine carrying far more load than it has cores.
Run it with tools/run_field_tests.py --id synth.pat-spatial.engine.
src/jvmMain/kotlin/afield/audio/synth/PatSpatialEngineSmoke.kt — read the source
PatSpatialEngineSmoke
Plays a spatialised pattern through the real FieldAudioEngine, and checks its thread pulled it.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
PatSpatialSmokeTest.kt
The audio check on patSpatial, run without an audio device: patterns that differ only in a
spatial tag, and the samples that came out of each.
This file and HeadTrackedSpatialSmokeTest both pull PatSpatialBank themselves and compare
captures against each other rather than against remembered levels, which is what lets them run on
a machine with no sound card. PatSpatialEngineSmoke covers the half they cannot reach, and
PatSpatialBudgetProbe measures what a block costs.
Of the six spatial tags patSpatial reads, az, dist, occl and spat each get a case here;
elev and air get none. The remaining cases cover polyphony inside one source, turnListener,
and whether the ring absorbed the render thread's worst block.
Run it with tools/run_field_tests.py --id synth.pat-spatial.smoke.
src/jvmMain/kotlin/afield/audio/synth/PatSpatialSmokeTest.kt — read the source
PatSpatialSmokeTest
Proves the whole route -- pattern language, Dough, Steam Audio HRTF -- on real audio, in realtime.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
PatSynth.kt
A pattern on the audio seam: pat(...) driving Field's own voices in realtime.
Making a sound in Field divides everything that makes
samples by one signature, render(outL, outR, numFrames, ctx), and a path on that seam composes
with every other path on it. patRenderStereo is not on it: it is a free function that returns a
whole buffer, because a pattern has to be queried over an arc and there is no "next 128 frames"
to ask it for without a scheduler. This file is that scheduler, for Field's own voices.
Three parts, split by where they run:
| where it runs | what it does | |
|---|---|---|
PatSynthFeeder |
a daemon thread | queries the pattern a cycle at a time and turns events into frame-stamped PatSynthNotes |
PatSynthBank |
the audio thread | starts a note on the frame it was given, and synthesises |
PatSynthLaunch |
neither | owns both, and closes both |
Querying a pattern allocates, and a cycle of a large composed pattern is not a 2.67 ms job, so the
query cannot be on the audio thread. It does not have to be: what it produces is frames, and
being 400 ms early is the same sound as being exactly on time. The audio thread's remaining job is
an integer comparison. FaustPatternFeeder splits the work the same way for a Faust voice bank,
and the two are deliberate siblings.
One voice, and the .wav that records it
One oscillator, one exponential-decay envelope, one one-pole lowpass, reading the same gain,
pan, cutoff, sustain, note and speed annotations off each event and using the same
patDrumHz map for unpitched atoms as patRenderStereo does.
What makes a .wav on a documentation page a record of the realtime path rather than a second
implementation of it is patSynthRenderStereo: it drives this bank through a SynthContext
block by block, the way FieldAudioEngine does, and returns what came out. No audio device is
involved and nothing is re-derived.
The output is the same at any block size
A scheduler that rounded each note to a block boundary would sound fine and be wrong, and the
symptom is that its output changes when the engine's buffer size does. patSynthRenderStereo takes
blockFrames, and PatSynthDocs renders three bars at 64, 128, 512 and 1024 and asserts the four
buffers are bit-identical. That is the property that makes this a scheduler rather than a
quantiser.
The mixing loop runs samples outside and voices inside, and that is what makes the property exact rather than close. The other way round, every note due in a block starts before any voice in that block has finished, so which slot a note lands in moves with the block boundaries and so does the order its samples are summed in; float addition is not associative, and a sixteen-voice bar reads one ULP apart at two buffer sizes. Deciding starts and ends per sample costs one branch per voice per sample.
src/jvmMain/kotlin/afield/audio/synth/PatSynth.kt — read the source
Used in: A pattern that moves particles
Top-level functions
PatSynthNote
One note, stamped with the absolute frame it starts on.
| Declaration | Summary |
|---|---|
val startFrame: Long |
Absolute frame, on PatSynthBank.frame's clock. |
val holdFrames: Int |
Frames to hold at full before the decay starts. |
val tailFrames: Int |
Frames of decay tail to keep rendering after the hold. |
val hz: Double |
|
val wave: PatWave |
|
val gain: Double |
|
val pan: Double |
Zero is hard left, one is hard right, matching the pan annotation. |
val cutoffHz: Double? |
One-pole lowpass corner, or null for no filter. |
val seed: Long |
|
val frames: Int |
Total frames this note occupies, which is what a voice is reserved for. |
PatSynthBank
A fixed bank of patVoice voices on the audio seam, played by frame-stamped PatSynthNotes.
| Declaration | Summary |
|---|---|
val voice: PatVoiceSpec |
|
val voices: Int |
|
val sampleRate: Int |
|
val masterGain: Double |
|
val frame: Long |
Frames rendered so far. The clock a PatSynthNote's startFrame is on. |
var queued: Int |
Notes handed over but not yet started. Read it to see whether the feeder is keeping up. |
var stolen: Long |
Notes that started on a voice still sounding, because every voice was busy. |
fun schedule(notes: Collection<PatSynthNote>) |
Hand notes to the audio thread. Safe from any thread; allocates nothing on the audio side. |
fun schedule(note: PatSynthNote) |
|
fun allNotesOff() |
Silence everything sounding and forget everything scheduled, at the top of the next block. |
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) |
|
override fun toString(): String |
PatSynthFeeder
Feeds pattern into bank a cycle at a time, aheadSeconds ahead of the audio thread.
| Declaration | Summary |
|---|---|
val pattern: PatPattern<String> |
|
val cps: Double |
|
val voice: PatVoiceSpec |
|
val gain: Double |
|
val aheadSeconds: Double |
|
var cyclesFed: Long |
Cycles queried so far. |
fun stop() |
Stop querying. Notes already scheduled still play; PatSynthBank.allNotesOff silences those. |
val isRunning: Boolean |
|
override fun toString(): String |
PatSynthLaunch
A pattern playing through a PatSynthBank, as one thing a box can return.
| Declaration | Summary |
|---|---|
val bank: PatSynthBank |
|
val feeder: PatSynthFeeder |
|
val tap: StereoScope |
A tap on this launch's own output, fed from the audio thread. |
override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) |
|
val pulledByEngine: Boolean |
Whether an audio engine has pulled this launch in the last half second. |
fun advanceOffline(frames: Int) |
Pull frames through the bank and into tap, for when nothing else is pulling it. |
override fun close() |
|
override fun toString(): String |
PatSynthDocs.kt
The scenes and the checks for patSynth, the pattern scheduler that sits on the audio seam.
Every .wav here comes out of patSynthRenderStereo, which drives a real PatSynthBank through a
SynthContext block by block exactly as FieldAudioEngine would. So these files are a record of
the realtime path with no audio device attached, rather than a second implementation of it.
Three properties of the scheduler
Block size does not change the output. The same bar rendered at 64, 128, 512 and 1024 frames per block comes back bit-identical. A scheduler that started each note at the top of the block it fell in would pass every level and onset check here, and fail this one.
The seam voice is the offline voice. patRenderStereo and patSynthRenderStereo render the
same bar to the same onset counts and the same levels, but not to the same bytes: the offline
renderer works in seconds and truncates, this one works in frames and rounds, so a note can
differ by a frame at each end and a dense passage by a few merged onsets.
The band analysis separates a kick from a hat. BandSplit over a kick-only render and a
hat-only render has to order its low and high bands opposite ways round. That is the smallest claim
that says the filters are filtering, and it is the property anything driving a display off
BandSplit rests on.
src/jvmMain/kotlin/afield/audio/synth/PatSynthDocs.kt — read the source
PatSynthDocs
The three scenes, the three scheduler checks, and the renderer that writes the .wavs out.
| Declaration | Summary |
|---|---|
const val SR: Int |
|
val BLOCK_SIZES: List<Int> |
Block sizes the invariance check sweeps. 64 is smaller than any engine buffer, 1024 larger. |
class Scene(val name: String, val title: String, val blurb: String, val cycles: Int, val cps: Double, val voice: PatVoiceSpec, val pattern: () -> PatPattern<String>, val expectedOnsets: Int, val onsetThreshold: Double = 0.05) |
One scene: a pattern, a tempo, a voice, and what it is for. |
val SCENES: List<Scene> |
|
fun renderWav(name: String, outPath: String) |
Offline-render scene name and write a stereo 16-bit PCM WAV to outPath. |
fun bandSeries(audio: PatSoundDocs.Stereo, split: BandSplit, windowFrames: Int = 2048, hopFrames: Int = 512): List<DoubleArray> |
The per-band reading for a whole render, as the docs page plots it. |
fun main(args: Array<String>) |
PatTranscription.kt
Recovered audio, written back out as notation Field can re-render.
public/docs/audio_render_analysis.md draws the loop: notate, render, analyse, revise. This file is
the last arrow. Given a rendered buffer it returns a pat("…") string and the PatPattern it
parses to, so the output of analysis is the same kind of object as the input to synthesis and the
loop closes without a human in the middle of it. It is Field's only mini-notation printer, in
either direction; PatEvent.sourceSpan hands back the substring that produced an event, which is
the opposite problem.
What is recoverable and what is not. A step grid is recoverable from onsets; a cycle length is
not. Eight notes a beat apart are equally well "eight steps in one cycle at 0.5 cps" and "four steps
per cycle over two cycles at 1 cps", and no amount of listening distinguishes them, because the two
render to identical samples. So patTranscribe recovers the grid, makes the one arbitrary choice it
has to (one cycle, however many steps it found), and reports the PatTranscription.cps that makes
that choice sound right. What comes back is faithful to the audio rather than to the authoring
decision that produced it.
A rest and a refusal are different, and mini-notation cannot say so. ~ means "nothing sounds
here". There is no token for "something sounds here and a monophonic detector will not pretend to
name it". So a span whose harmonic residual says chord is dropped from PatTranscription.notes into
PatTranscription.declined, appears in the emitted string as ~, and is named explicitly in
PatTranscription.warnings. So the notation is what the detector will stand behind and the
warnings are the rest of what it heard.
src/jvmMain/kotlin/afield/audio/synth/PatTranscription.kt — read the source
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val PAT_GRID_MAX_STEPS: Int |
Largest step count patEstimateStepGrid will consider fitting into one render. |
PatStepGrid
A step grid fitted to a set of onsets.
| Declaration | Summary |
|---|---|
val stepSeconds: Double |
|
val worstResidual: Double |
|
val steps: Int |
|
val acceptedAt: Double |
The tolerance this grid was actually accepted at. See patEstimateStepGrid. |
val cps: Double |
Cycles per second that makes one cycle of steps steps last the whole analysed span. |
fun describe(): String |
PatTranscription
Everything recovered from one buffer: the notes, the grid, and the notation that re-renders them.
| Declaration | Summary |
|---|---|
val notes: List<PatDetectedNote> |
|
val declined: List<PatDetectedNote> |
|
val frames: List<PatF0Frame> |
|
val sampleRate: Int |
|
val totalSeconds: Double |
|
val grid: PatStepGrid? |
|
val miniNotation: String |
|
val sustainFraction: Double |
|
val warnings: List<String> |
|
val diagnosis: PatPitchDiagnosis |
Why nothing was named, when nothing was. PatPitchVerdict.PITCHED whenever notes is not empty. |
val steps: Int |
Steps in one cycle of miniNotation. |
val cps: Double |
Cycles per second at which miniNotation reproduces the analysed timing. |
val pattern: PatPattern<String> |
The recovered notation as a pattern. One cycle of it is the whole analysed span. |
val midiSequence: List<Int> |
MIDI numbers in time order, which is the sequence a caller usually wants to compare. |
fun describe(): String |
PatLoopVerdict
What happened when the emitted notation was rendered again and listened to again.
Constants: CLOSED, DIVERGED, REFUSED, NO_GRID.
PatLoopClosure
One full turn of notation → sound → notation → sound.
| Declaration | Summary |
|---|---|
val label: String |
|
val verdict: PatLoopVerdict |
|
val firstNotes: Int |
|
val secondNotes: Int |
|
val firstMidi: List<Int> |
|
val secondMidi: List<Int> |
|
val worstOnsetShift: Double |
|
val centsShift: Double |
|
val toleranceSeconds: Double |
|
val quantisationBound: Double |
What the grid's own quantisation would have allowed. Reported, not a gate. See patCloseLoop. |
val unpaired: Int |
|
val why: String |
|
val closed: Boolean |
|
fun describe(): String |
PatRefNote
data class PatRefNote(val startSample: Int, val endSample: Int, val midi: Double)
Reference note, as the notation that produced the audio says it should be.
| Declaration | Summary |
|---|---|
val startSample: Int |
|
val endSample: Int |
|
val midi: Double |
PatTranscriptionScore
How a transcription compared with the notation that generated the audio.
| Declaration | Summary |
|---|---|
val label: String |
|
val reference: Int |
|
val recovered: Int |
|
val declined: Int |
|
val onsetMatched: Int |
|
val pitchCorrect: Int |
|
val octaveErrors: Int |
|
val onsetPrecision: Double |
|
val onsetRecall: Double |
|
val noteF: Double |
|
val centsMedian: Double |
|
val centsP90: Double |
|
val centsWorst: Double |
|
val onsetMedianMs: Double |
|
val onsetWorstMs: Double |
|
fun describe(): String |
PatOnsetTally
data class PatOnsetTally(val observed: Int, val scheduled: Int, val matched: Int)
How many of scheduledSamples some onset in observedSamples landed on.
| Declaration | Summary |
|---|---|
val recall: Double |
|
val precision: Double |
|
fun describe(label: String): String |
PatTranscriptionCorpus.kt
A corpus that scores a transcription against the notation that generated the audio.
The ground truth here costs nothing and is exact. Transcription research is normally bounded by
annotation: someone listens to a recording, writes down what they think is in it, and the
transcriber is measured against a human's opinion. Field authored the notation, rendered it through
its own synthesiser, and still has the notation, so the reference is the audio's cause rather than
a reading of it. Every number in PatTranscriptionCorpus is a comparison against that.
referenceOf therefore mirrors patSynthNotesForArc, which patRenderStereo schedules through,
rather than reimplementing the schedule: same events, same sort, same
arc.start · secondsPerCycle, same Math.round onto frames for the start and the hold, and no
hasOnset filter, because the renderer has none either. A reference computed from what the schedule
ought to be would be measuring this file's understanding of the renderer instead of the detector.
Half the corpus is designed to fail. A harness that only contains material its detector handles
measures the tuning rather than the range. So there is a legato line
with no attacks in it, a triad a monophonic detector must refuse rather than name, a line fast enough
to sit under the analysis window, a noise train with no pitch in it at all, and square and sawtooth
timbres whose harmonic stacks are the classic octave-error traps. What each case is for is written
into PatTranscriptionCase.purpose and printed with its score, so a regression shows up as a
sentence rather than a delta.
The *-untold cases exist to measure one design claim rather than assert it: that telling the
detector the register it is listening to — the same contract patDetectOnsetSamples has with
minGapSeconds — is worth more than any threshold. The pair differs in nothing but whether
minHz/maxHz come from the notation or from the wide default.
src/jvmMain/kotlin/afield/audio/synth/PatTranscriptionCorpus.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun patSteppedSine(midiSteps: List<Double?>, stepSeconds: Double, amplitude: Float = 0.5f): Signal |
A legato line patRenderStereo cannot produce: one oscillator, continuous phase, stepped frequency. |
PatTranscriptionCase
One notated pattern, rendered and then measured.
| Declaration | Summary |
|---|---|
val label: String |
|
val notation: String |
|
val stepsPerCycle: Int |
|
val cycles: Int |
|
val cps: Double |
|
val voice: PatVoiceSpec |
|
val purpose: String |
|
val tellRegister: Boolean |
|
val secondsPerCycle: Double |
|
val stepSeconds: Double |
|
val totalSeconds: Double |
|
fun pattern(): PatPattern<String> |
The pattern, parsed once per call exactly as the renderer would parse it. |
PatCaseBehaviour
What a case did, as opposed to how it scored.
Constants: RECOVERED_EXACTLY, RECOVERED_WITHIN, REFUSED, CONFIDENTLY_WRONG.
PatCaseVerdict
A case's behaviour, and the evidence for the classification.
| Declaration | Summary |
|---|---|
val label: String |
|
val behaviour: PatCaseBehaviour |
|
val wrongNames: Int |
|
val spuriousNotes: Int |
|
val missingNotes: Int |
|
val warnings: Int |
|
val why: String |
|
fun describe(): String |
PatCaseResult
Everything one case produced: the audio, the truth, what was heard, and how the two compare.
| Declaration | Summary |
|---|---|
val case: PatTranscriptionCase |
|
val sampleRate: Int |
|
val samples: FloatArray |
|
val reference: List<PatRefNote> |
|
val transcription: PatTranscription |
|
val score: PatTranscriptionScore |
|
val energyOnsets: List<Int> |
Onsets the energy detector found, tallied over the whole schedule. |
val energyTally: PatOnsetTally |
|
val energyMatch: PatOnsetMatch |
The same energy onsets scored with their constant lag forgiven, via patMatchOnsets. |
val pitchOnsets: List<Int> |
Onsets the pitch detector found, on the same buffer, against the same schedule. |
val pitchTally: PatOnsetTally |
|
val pitchMatch: PatOnsetMatch |
|
val mergedOnsets: List<Int> |
The union of the two, which is what patDetectNoteOnsets returns. |
val mergedTally: PatOnsetTally |
|
val closure: PatLoopClosure |
The fourth hop: the emitted notation rendered again and heard again. |
val verdict: PatCaseVerdict |
What this case did, as opposed to how it scored. |
val voicedFrames: Int |
|
fun describe(): String |
PatLegatoProbe
The legato probe's audio, truth and three onset tallies.
| Declaration | Summary |
|---|---|
val midiSteps: List<Double?> |
|
val stepSeconds: Double |
|
val sampleRate: Int |
|
val samples: FloatArray |
|
val reference: List<PatRefNote> |
|
val transcription: PatTranscription |
|
val score: PatTranscriptionScore |
|
val energyOnsets: List<Int> |
|
val energyTally: PatOnsetTally |
|
val energyMatch: PatOnsetMatch |
|
val pitchOnsets: List<Int> |
|
val pitchTally: PatOnsetTally |
|
val pitchMatch: PatOnsetMatch |
|
val mergedOnsets: List<Int> |
|
val mergedTally: PatOnsetTally |
|
val closure: PatLoopClosure |
|
fun describe(): String |
PatTranscriptionCorpus
The corpus, its renderer, and its scorer.
PatTranscriptionGallery.kt
The figure that makes the round trip something you look at rather than read about.
One panel per case, one shared MIDI axis, and the two note sets drawn on top of each other: the notation Field authored as a hollow outline, and what the detector heard as a solid bar inside it. Agreement is then a filled outline and disagreement is a bar with nothing around it or an outline with nothing in it — which is the whole reason to overlay rather than stack. A stacked pair of piano rolls asks the reader to compare two pictures; an overlay asks them to spot the exceptions.
Drawn as SVG strings into a StringBuilder, following PatPianoRollGallery exactly, including its
palette. Same reasons: a piano roll is line art, and a diffable figure shows a changed detector as a
changed rectangle rather than as a changed PNG blob. No AWT, no browser, no GPU, no Field window.
The F0 trace is drawn on the same axis as the notes, because it is in the same units, and it is the part of the figure that shows why a note was missed: a trace wandering between two outlines is a straddling window, and a trace that vanishes is the voicing gate closing. Neither is visible in a score table.
src/jvmMain/kotlin/afield/audio/synth/PatTranscriptionGallery.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun afield.document.graph.Box.showPatTranscription(name: String = "roundtrip", channel: String = "main") |
Renders a PatTranscriptionGallery figure into this box's output as an inline SVG. |
fun afield.document.graph.Box.showPatTranscriptionOf(samples: FloatArray, sampleRate: Int = PatSoundDocs.SR, reference: List<PatRefNote> = emptyList(), minGapSeconds: Double? = null, minHz: Double = PAT_PITCH_MIN_HZ, maxHz: Double = PAT_PITCH_MAX_HZ, label: String = "transcription", channel: String = "main"): PatTranscription |
Transcribes samples and draws the result against a notated reference in this box's output. |
fun patReferenceNotes(notation: String, cycles: Int, cps: Double, sustain: Double = 1.0, sampleRate: Int = PatSoundDocs.SR): List<PatRefNote> |
PatRefNotes for the notation that produced a buffer — the ground truth Field already owns. |
PatTranscriptionGallery
object PatTranscriptionGallery
Headless notated-against-recovered gallery for the pitch transcriber.
| Declaration | Summary |
|---|---|
class Panel(val label: String, val summary: String, val note: String, val sampleRate: Int, val totalSeconds: Double, val reference: List<PatRefNote>, val recovered: List<PatDetectedNote>, val declined: List<PatDetectedNote>, val frames: List<PatF0Frame>, val notation: String) |
One case's worth of evidence, in the form the figure needs. |
fun panelOf(result: PatCaseResult, note: String): Panel |
|
fun panelOf(probe: PatLegatoProbe, note: String): Panel |
|
fun renderSvg(title: String, subtitle: String, panels: List<Panel>): String |
Renders panels to a standalone SVG document. |
class Figure(val name: String, val title: String, val subtitle: String, val build: (List<PatCaseResult>, PatLegatoProbe) -> List<Panel>) |
One figure: name, and the panels it draws from a corpus run. |
val FIGURES: List<Figure> |
|
fun svgFor(name: String): String |
Renders figure name and returns its SVG source. |
fun main(args: Array<String>) |
Sequencing.kt
Notes with times on them, and a lazy player for a stream of them. ScheduledNote is the event,
noteProcess { … } turns a suspending block into a NoteProcess, and a NoteProcess draws itself
as a piano roll.
Time is in seconds and pitch is a MIDI number, fractional wherever you want it, with
midiToFrequency and frequencyToMidi to cross between the two. Inside the block the receiver is
a NoteSequenceScope carrying a cursor, so consecutive note(…) calls lay themselves out end to
end and rest(…) leaves a gap.
The block is a generator rather than a list, which is what makes while (true) a legitimate way to
write a sequence: a NoteProcess pulls from it only as far as the window it is asked about, keeps
a few seconds of what has already gone past, and drops the rest. Moving the playhead is what
unrolls more.
Nothing here makes a sound. A NoteProcess is a structure to read notes out of and to look at;
TidalPatterns.kt is the other way to write one, and the synthesis category is what plays it.
src/jvmMain/kotlin/afield/audio/synth/Sequencing.kt — read the source
Used in: Boolean network music examples, Visualizers, Displays, and Little Instruments
Top-level functions
ScheduledNote
One note: when it starts, how long it lasts, what pitch it is, and how hard.
| Declaration | Summary |
|---|---|
val start: Double |
|
val duration: Double |
|
val pitch: Float |
|
val velocity: Float |
|
val label: String? |
|
val color: String? |
|
val end: Double |
|
val frequencyHz: Double |
|
fun intersects(windowStartSeconds: Double, windowEndSeconds: Double): Boolean |
|
override fun toString(): String |
NoteWindowSnapshot
The notes visible in one span of time, taken around the playhead they were asked for.
| Declaration | Summary |
|---|---|
val notes: List<ScheduledNote> |
|
val playheadSeconds: Double |
|
val windowStartSeconds: Double |
|
val windowEndSeconds: Double |
|
val exhausted: Boolean |
|
val generatedUntilSeconds: Double |
|
override fun toHTML(): String |
|
override fun toString(): String |
NoteSequenceScope
class NoteSequenceScope internal constructor(private val scope: GeneratorScope<Unit, ScheduledNote>)
The receiver inside noteProcess { … }, and the cursor that lets a sequence read in the order it is played.
NoteProcess
A sequence of notes pulled from a generator on demand, with a playhead and a window onto it.
TidalPatterns.kt
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:
-
Parse:
1val hats = pat("bd [hh hh] sd") -
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, stackedArpeggiate 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) -
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().durationOr 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:
1val groove = pat("[bd cp](3,8) hh!2")A
korgin the same brackets asks for a Kolakoski figure over that many steps instead —kfor the self-describing run-length reading,gfor 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:
1val 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:1val randomChoice = pat("[bd*4|hh*12|cp*2]")Atom names can still carry sample-style parameter payloads:
1val parameterized = pat("bd:(1,2,3) hh")By default,
scheduled(...),respond(...),respondAhead(...), andasNotes(...)use the ambientPatClockfrom box context if one exists, otherwise they fall back tomusicClock(). Passclock = nullif you want a zero-based schedule instead. -
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 ) ) } -
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, anddurationon scheduled events are in seconds.cpsstill 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 targetstart.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 viawith(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.
src/jvmMain/kotlin/afield/audio/synth/TidalPatterns.kt — read the source
Used in: Patterns: writing rhythm as a function of time, A pattern as a function generator, Petri nets
Top-level functions
PatQ
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.
PatArc
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.
| Declaration | Summary |
|---|---|
val dur: PatQ |
|
fun contains(t: PatQ): Boolean |
|
fun shift(by: PatQ) |
|
fun scale(by: PatQ) |
|
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. |
fun intersect(other: PatArc): PatArc? |
|
override fun toString(): String |
PatParseFrame
| Declaration | Summary |
|---|---|
val className: String? |
|
val methodName: String? |
|
val fileName: String? |
|
val lineNumber: Int |
|
override fun toString(): String |
PatParseContext
| Declaration | Summary |
|---|---|
val source: String |
|
val lineHint: Int? |
|
val stackTrace: List<PatParseFrame> |
|
override fun toString(): String |
PatSourceSpan
data class PatSourceSpan(val context: PatParseContext, val start: Int, val endExclusive: Int)
| Declaration | Summary |
|---|---|
val context: PatParseContext |
|
val start: Int |
|
val endExclusive: Int |
|
val source: String |
|
val lineHint: Int? |
|
val stackTrace: List<PatParseFrame> |
|
val text: String |
|
val length: Int |
|
override fun toString(): String |
PatEvent
One event: a value sounding over arc.
| Declaration | Summary |
|---|---|
val arc: PatArc |
|
val value: A |
|
val sourceSpan: PatSourceSpan? |
|
val annotations: Map<String, String> |
|
val additionalSpans: List<PatSourceSpan> |
|
val whole: PatArc? |
|
val hasOnset: Boolean |
True when this fragment carries the start of its event, so a scheduler should strike it. |
val extent: PatArc |
The event's real length — whole where there is one, otherwise the part that was visible. |
fun shift(by: PatQ) |
|
fun scale(by: PatQ) |
|
inline fun mapTime(f: (PatQ) -> PatQ) |
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 clipTo(part: PatArc) |
Restricts the visible part to part without touching where the event logically begins. |
fun mergeAnnotations(extra: Map<String, String>) |
|
fun addSpan(span: PatSourceSpan?) |
|
override fun toString(): String |
PatScheduledEvent
| Declaration | Summary |
|---|---|
val start: Double |
|
val end: Double |
|
val value: A |
|
val cycleIndex: Long |
|
val arc: PatArc |
|
val sourceSpan: PatSourceSpan? |
|
val annotations: Map<String, String> |
|
val additionalSpans: List<PatSourceSpan> |
|
val duration: Double |
|
val sourceText: String? |
|
operator fun get(key: String): String? |
|
val allSpans: List<PatSourceSpan> |
|
override fun toString(): String |
PatPattern
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.
ArpMode
Constants: UP, DOWN, UPDOWN, DOWNUP, RANDOM, PINGPONG.
MiniPat
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).
| Declaration | Summary |
|---|---|
val cycle: Long |
The 0-based cycle index of the current query. |
val t: Double |
Fractional position inside the queried sub-arc (0.0 .. 1.0). |
PatResponseScope
| Declaration | Summary |
|---|---|
val box: Box |
|
val pattern: PatPattern<A> |
|
val cps: Double |
|
val clock: PatClock? |
|
val ahead: Double |
|
val launchInstant: Instant |
|
val launchElapsed: Double |
|
val now: Double |
|
override fun toString(): String |
PatResponseSpec
| Declaration | Summary |
|---|---|
override fun toString(): String |