PatSignals.kt
src/jvmMain/kotlin/afield/audio/synth/PatSignals.kt · 311 lines
@file:afield.runtime.completion.CompletionCategory("Patterns")
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.
package afield.audio.synth
import java.math.BigInteger
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.sin
the core: a signal is a pattern that answers every arc
fun patArcMidpoint(arc: PatArc): PatQ = (arc.start + arc.end) / PatQ.of(2)
The exact rational midpoint of arc. Used as the sampling instant for every signal.
fun signalOf(sample: (exact: PatQ, t: Double) -> Double): PatPattern<Double> = PatPattern { arc ->
Builds a continuous pattern from a function of cycle time.
The function receives the exact rational instant it is being asked about as well as its
Double approximation, so a signal can stay exact if it wants to (quantise, run, index
arithmetic) while trigonometric signals just use the Double.
Queried with any arc, the result is a single event spanning that whole arc whose value is sampled at patArcMidpoint. See the file header for why.
The event is analog — whole == null — because a continuous signal has no note to start: ask
for a different window and you get a different event with a different value, so there is nothing
for a scheduler to strike once. segment is how you give a signal real onsets.
fun signalOf(sample: (exact: PatQ, t: Double) -> Double): PatPattern<Double> = PatPattern { arc ->
val mid = patArcMidpoint(arc)
listOf(PatEvent(arc, sample(mid, mid.toDouble()), whole = null))
}
fun sig(sample: (t: Double) -> Double): PatPattern<Double> = signalOf { _, t -> sample(t) }
Builds a continuous pattern from a plain function of cycle time. sig { t -> t * t }.
fun constant(value: Number): PatPattern<Double> = signalOf { _, _ -> value.toDouble() }
Constant continuous signal — the identity of the numeric operator family.
the standard signal family (all unipolar 0..1, like Tidal)
private fun cyclePhase(t: Double): Double {
val f = t - floor(t)
return if (f < 0.0) f + 1.0 else f
}
fun sine(): PatPattern<Double> = sig { t -> 0.5 + 0.5 * sin(2.0 * PI * t) }
0.5 + 0.5·sin(2πt) — one full cycle of a sine per pattern cycle, in 0..1.
fun cosine(): PatPattern<Double> = sig { t -> 0.5 + 0.5 * cos(2.0 * PI * t) }
0.5 + 0.5·cos(2πt) — sine a quarter cycle early, in 0..1.
fun saw(): PatPattern<Double> = sig { t -> cyclePhase(t) }
A rising ramp 0 → 1 once per cycle.
fun isaw(): PatPattern<Double> = sig { t -> 1.0 - cyclePhase(t) }
A falling ramp 1 → 0 once per cycle.
fun tri(): PatPattern<Double>
Up then down: 0 → 1 → 0 once per cycle.
fun tri(): PatPattern<Double> = sig { t ->
val p = cyclePhase(t)
if (p < 0.5) 2.0 * p else 2.0 - 2.0 * p
}
fun square(): PatPattern<Double>
0 for the first half of each cycle, 1 for the second — exactly, on the rational midpoint.
fun square(): PatPattern<Double> = signalOf { exact, _ ->
val p = exact - PatQ.of(qFloor(exact))
if (p < PatQ.of(1, 2)) 0.0 else 1.0
}
fun envL(): PatPattern<Double> = sig { t -> t.coerceIn(0.0, 1.0) }
A one-shot linear rise 0 → 1 across the first cycle, then held at 1.
fun envLR(): PatPattern<Double> = sig { t -> 1.0 - t.coerceIn(0.0, 1.0) }
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.
Because the hash reads n and d of the reduced fraction rather than a Double, the same
musical position always produces the same number, on any machine, at any query granularity.
fun rand(salt: Int = 0): PatPattern<Double> = signalOf { exact, _ ->
toUnitDouble(splitmix64(patQHash(exact) xor (salt.toLong() * 0x9E3779B97F4A7C15uL.toLong())))
}
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 irand(n: Int, salt: Int = 0): PatPattern<Double> {
val count = max(1, n)
return rand(salt).map { u -> floor(u * count).coerceIn(0.0, (count - 1).toDouble()) }
}
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 perlin(salt: Int = 0): PatPattern<Double> = sig { t ->
val i = floor(t)
val f = t - i
val a = toUnitDouble(splitmix64(i.toLong() * 0x2545F4914F6CDD1DuL.toLong() xor salt.toLong()))
val b = toUnitDouble(splitmix64((i.toLong() + 1L) * 0x2545F4914F6CDD1DuL.toLong() xor salt.toLong()))
val s = f * f * (3.0 - 2.0 * f)
a + (b - a) * s
}
Hash of an exact rational — the reduced numerator and denominator, so it is granularity-free.
private fun patQHash(q: PatQ): Long {
var h = -0x340d631b7bdddcdbL
for (part in listOf(q.n, q.d)) {
for (b in part.toByteArray()) {
h = (h xor b.toLong()) * 0x100000001B3L
}
h = (h xor 0x5DEECE66DL) * 0x100000001B3L
}
return h
}
discrete generators that read like signals
fun run(n: Int): PatPattern<Double>
0 1 2 … n-1 as n events per cycle, exactly on the 1/n grid.
fun run(n: Int): PatPattern<Double> {
val count = max(1, n)
return fromCycle { subArc ->
buildList {
for (index in 0 until count) {
val cell = PatArc(PatQ.of(index, count), PatQ.of(index + 1, count))
val inter = cell.intersect(subArc) ?: continue
add(PatEvent(inter, index.toDouble(), whole = cell))
}
}
}
}
fun grid(n: Int): PatPattern<Boolean> = euclid(max(1, n), max(1, n))
The 1/n grid as a boolean pattern — the structure source for struct and segment.
discretisers
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.
The source is sampled over the whole cell, not over whatever arc the caller happened to
ask about, and only the emitted event is clipped — so segment is query-granularity
independent. For a continuous pattern that means each cell gets the value at its own centre.
fun <A> PatPattern<A>.segment(n: Int): PatPattern<A> {
val count = max(1, n)
val cellWidth = PatQ.of(1, count)
return PatPattern { absArc ->
val firstCell = qFloorDiv(absArc.start, cellWidth)
val lastCell = qCeilDiv(absArc.end, cellWidth) - 1
val out = mutableListOf<PatEvent<A>>()
for (cell in firstCell..lastCell) {
val cellArc = PatArc(cellWidth * PatQ.of(cell), cellWidth * PatQ.of(cell + 1))
val visible = cellArc.intersect(absArc) ?: continue
val source = events(cellArc)
val chosen = source.firstOrNull { it.arc.contains(patArcMidpoint(cellArc)) }
?: source.firstOrNull()
?: continue
// The cell is the new event, so `cellArc` is its whole even when the query clips it.
// This is exactly what discretising a signal means: analog in, real onsets out.
out += PatEvent(visible, chosen.value, chosen.sourceSpan, chosen.annotations, chosen.additionalSpans, whole = cellArc)
}
out
}
}
fun <A> PatPattern<A>.discretise(n: Int): PatPattern<A> = segment(n)
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.
struct(euclid(3, 8), pat("bd")) is a Euclidean bass drum; struct(pat("t f t t"), pat("a b"))
reads the boolean pattern as a mask. Value lookup is appLeft, so a continuous values
pattern is sampled at the midpoint of each structural event.
fun <A> struct(structure: PatPattern<Boolean>, values: PatPattern<A>): PatPattern<A> = PatPattern { arc ->
structure.events(arc).mapNotNull { structural ->
if (!structural.value) return@mapNotNull null
val valued = values.patSampleOver(structural.arc) ?: return@mapNotNull null
PatEvent(
structural.arc,
valued.value,
valued.sourceSpan ?: structural.sourceSpan,
structural.annotations + valued.annotations,
structural.additionalSpans + valued.additionalSpans,
// Rhythm comes from `structure`, so its whole is the one that matters — a sampled
// continuous `values` contributes no onset of its own.
whole = structural.whole
)
}
}
fun bools(source: String): PatPattern<Boolean>
Boolean structure from mini-notation t/f (also true/false, 1/0, x/~).
fun bools(source: String): PatPattern<Boolean> = pat(source).map { token ->
when (token.lowercase()) {
"t", "true", "1", "x" -> true
else -> false
}
}
numeric shaping
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>.range(lo: Number, hi: Number): PatPattern<Double> {
val a = lo.toDouble()
val b = hi.toDouble()
return map { v -> a + v * (b - a) }
}
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>.rangex(lo: Number, hi: Number): PatPattern<Double> {
val a = lo.toDouble().coerceAtLeast(1e-9)
val b = hi.toDouble().coerceAtLeast(1e-9)
return map { v -> a * Math.pow(b / a, v) }
}
fun PatPattern<Double>.bipolar(): PatPattern<Double> = map { v -> v * 2.0 - 1.0 }
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>.quantise(step: Number): PatPattern<Double> {
val s = step.toDouble()
if (s <= 0.0) return this
return map { v -> Math.round(v / s).toDouble() * s }
}
fun PatPattern<Double>.rounded(): PatPattern<Int> = map { v -> Math.round(v).toInt() }
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<String>.nums(): PatPattern<Double> = PatPattern { arc ->
events(arc).mapNotNull { e ->
e.value.trim().toDoubleOrNull()?.let { v -> e.withValue(v) }
}
}
fun PatPattern<Double>.texts(): PatPattern<String> = map { patFormatNumber(it) }
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.
Named parameters live in PatEvent.annotations, which is a Map<String, String>, so every
numeric parameter round-trips through text. Nine decimal places is the chosen resolution: far
finer than any audible parameter (a cutoff in Hz, a gain in 0..1) and still short enough that
an event prints readably in a box output.
fun patFormatNumber(v: Double): String {
if (!v.isFinite()) return "0"
if (v == Math.rint(v) && Math.abs(v) < 1e15) return v.toLong().toString()
return java.math.BigDecimal(v).setScale(9, java.math.RoundingMode.HALF_UP)
.stripTrailingZeros().toPlainString()
}
exact rational grid helpers
internal fun qFloorDiv(value: PatQ, step: PatQ): Long = qFloor(value / step)
floor(value / step) in exact rational arithmetic.
internal fun qCeilDiv(value: PatQ, step: PatQ): Long = qCeil(value / step)
ceil(value / step) in exact rational arithmetic.
internal fun qIsInteger(q: PatQ): Boolean = q.d == BigInteger.ONE
True when q is an exact integer.