MarfSmokeTest.kt
src/jvmMain/kotlin/afield/audio/synth/MarfSmokeTest.kt · 268 lines
@file:afield.runtime.completion.CompletionCategory("Patterns")
package afield.audio.synth
import kotlin.math.roundToInt
FIELD_TEST id: synth.marf.readers runner: jvm-main entrypoint: afield.audio.synth.MarfSmokeTest tags: [smoke, synth, pattern, marf] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "MARF reader smoke tests passed"
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.
object MarfSmokeTest
Prints two readers of one shared bank as text, then asserts what transforming one reader's address may and may not change.
main is the synth.marf.readers FIELD_TEST: it writes nothing, and reports every failed
assertion before exiting non-zero.
object MarfSmokeTest {
private const val COLUMNS_PER_CYCLE = 8
private const val CYCLES = 8
private val LEVELS = "▁▂▃▄▅▆▇█"
The shared material. Five stages, of unequal length and unequal shape.
private fun bank(): MarfBank<Double> = marfBank(
rampTo(0.1, 1.0, label = "a"),
rampTo(0.9, 0.5, label = "b"),
holdAt(0.4, 2.0, label = "c"),
oscTo(0.7, 1.0, cycles = 3.0, label = "d"),
curveTo(0.2, 0.25, amount = 4.0, label = "e")
)
fun main(args: Array<String>)
body — 119 lines, from line 107
@JvmStatic
fun main(args: Array<String>) {
val failures = mutableListOf<String>()
println("── one bank, two readers ───────────────────────────────────────────────")
val shared = bank()
val a = shared.read(pat("<0 1 2 1 3 4>").nums(), name = "A")
val b = shared.read(pat("<0 2 4 1 3>").nums().slow(3), name = "B")
val plainA = render(a)
val plainB = render(b)
print(plainA)
print(plainB)
println()
println("── the same bank, one reader's address reversed every fourth cycle ─────")
val flipped = shared.read(pat("<0 1 2 1 3 4>").nums().every(4) { it.rev() }, name = "A'")
val flippedB = shared.read(pat("<0 2 4 1 3>").nums().slow(3), name = "B")
print(render(flipped))
print(render(flippedB))
// `<…>` holds one event per cycle, so rev() inside a cycle is a no-op — a Tidal fact, and
// the reason the interesting reversal below uses an address with steps inside the cycle.
if (render(flippedB) != plainB) failures += "transforming A's address changed B"
if (shared.stages.map { it.label } != listOf("a", "b", "c", "d", "e")) {
failures += "the bank was mutated by being read"
}
println()
println("── four stages a cycle, so rev() has something to reverse ──────────────")
val quarters = constant(0.25)
val stepped = shared.read(pat("0 1 2 3").nums(), duration = quarters, name = "C")
val reversed = shared.read(
pat("0 1 2 3").nums().every(4) { it.rev() },
duration = quarters,
name = "C'"
)
print(render(stepped))
print(render(reversed))
if (render(stepped) == render(reversed)) failures += "every(4) { rev() } changed nothing"
val firstCycle = (0..3).map { reversed.at(it * 0.25 + 0.05).address }
if (firstCycle != listOf(3.0, 2.0, 1.0, 0.0)) failures += "cycle 0 not reversed: $firstCycle"
val secondCycle = (4..7).map { reversed.at(it * 0.25 + 0.05).address }
if (secondCycle != listOf(0.0, 1.0, 2.0, 3.0)) failures += "cycle 1 was touched: $secondCycle"
println()
println("── a continuous address glides through the bank ────────────────────────")
// A periodic address is latched once per stage, so it is only ever a *sequence* of
// lcm(address period, stage period) / stage period steps. Eight cycles of saw against
// quarter-cycle stages is 32 of them, which reads as a glide; one cycle of sine against the
// same stages is four, which reads as a four-step sequencer. See the note in `Marf.kt`.
val glide = shared.read(saw().slow(8).range(0.0, 4.0), duration = quarters, name = "~")
print(render(glide))
val addresses = (0 until 32).map { glide.at(it * 0.25 + 0.05).address }
if (addresses.count { it % 1.0 > 1e-4 } < 20) {
failures += "a continuous address mostly landed on whole slots: $addresses"
}
if (addresses.zipWithNext().any { (p, q) -> q <= p }) {
failures += "a rising address did not rise monotonically: $addresses"
}
val aliased = shared.read(sine().range(0.0, 4.0), duration = quarters, name = "aliased")
val aliasedAddresses = (0 until 32).map { aliased.at(it * 0.25 + 0.05).address }
// Count distinct addresses to 3dp, not as raw doubles: an aliased address repeats to
// within a few parts in 10^4 rather than bit-exactly, because the probe instant is
// rationally exact but `PatQ.toDouble()` rounds it at 12 places. Raw `distinct()` reports
// 17 "different" addresses for a sequence that visibly cycles through four.
fun coarse(xs: List<Double>) = xs.map { "%.3f".format(it) }.distinct().size
println(" saw slowed 8x: ${coarse(addresses)} distinct addresses over 32 stages")
println(" plain sine: ${coarse(aliasedAddresses)} distinct addresses over 32 stages")
println(" plain sine, first eight: " + aliasedAddresses.take(8).joinToString(" ") { "%.4f".format(it) })
if (coarse(aliasedAddresses) > 6) failures += "the one-cycle address did not alias: ${coarse(aliasedAddresses)}"
if (coarse(addresses) < 24) failures += "the eight-cycle address did not glide: ${coarse(addresses)}"
println()
println("── three readers of one bank as three coordinates ──────────────────────")
val x = shared.read(pat("<0 1 2 3 4>").nums(), name = "x")
val y = shared.read(pat("<0 2 4 1 3>").nums().slow(3), name = "y")
val r = shared.read(sine().slow(5).range(0.0, 4.0), duration = constant(0.5), name = "r")
println(" cycle x y r stage x/y/r")
for (step in 0 until 12) {
val t = step * 1.1
val sx = x.at(t)
val sy = y.at(t)
val sr = r.at(t)
println(
" %5.2f %6.3f %6.3f %6.3f %s / %s / %s".format(
t, sx.value, sy.value, sr.value, sx.label, sy.label, sr.label
)
)
}
if (x.at(3.0).value == y.at(3.0).value && y.at(3.0).value == r.at(3.0).value) {
failures += "three readers of one bank agreed exactly, which means they are not independent"
}
println()
println("── pulses come from the same structure as the curve ────────────────────")
val pulses = a.triggers().events(PatArc(PatQ.ZERO, PatQ.of(CYCLES.toLong())))
val stages = a.stagesIn(PatArc(PatQ.ZERO, PatQ.of(CYCLES.toLong())))
println(" ${stages.size} stages over $CYCLES cycles, ${pulses.size} pulses")
if (pulses.size != stages.size) failures += "pulse count ${pulses.size} != stage count ${stages.size}"
val rebuilt = shared.read(pat("<0 1 2 1 3 4>").nums(), name = "A-again")
val gated = marf(
value = pat("0 .3 .6 .9").nums(),
duration = quarters,
trigger = bools("1 0 0 1")
)
val gatedPulses = gated.triggers().events(PatArc(PatQ.ZERO, PatQ.ONE)).size
println(" a `1 0 0 1` trigger over four stages a cycle gives $gatedPulses pulses")
if (gatedPulses != 2) failures += "trigger gating gave $gatedPulses pulses, expected 2"
if (rebuilt.at(1.5).value != a.at(1.5).value) failures += "two identically built readers disagreed"
println()
if (failures.isEmpty()) {
println("MARF reader smoke tests passed")
} else {
failures.forEach { println("FAIL: $it") }
error("MARF reader smoke tests failed: ${failures.size} failure(s)")
}
}
The five rows of the design note's first test, as text.
bound marks a stage boundary, addr is the integer part of the current address (~ when it
is between two slots), phase and value are sparklines, and pulse marks the stages that
emitted one. Every column is a real MarfReader.at call, so the picture cannot flatter the
reader it illustrates.
private fun render(reader: MarfReader<Double>): String {
val columns = COLUMNS_PER_CYCLE * CYCLES
val samples = (0 until columns).map { reader.at((it + 0.5) / COLUMNS_PER_CYCLE) }
val boundaries = BooleanArray(columns)
var previousIndex = -1L
for ((column, sample) in samples.withIndex()) {
if (sample.stageIndex != previousIndex) boundaries[column] = true
previousIndex = sample.stageIndex
}
fun row(label: String, body: String) = " %-6s %s\n".format(label, body)
val name = reader.name
return buildString {
append(" reader $name\n")
append(row("cycle", (0 until columns).joinToString("") {
if (it % COLUMNS_PER_CYCLE == 0) (it / COLUMNS_PER_CYCLE).toString().last().toString() else "·"
}))
append(row("bound", (0 until columns).joinToString("") { if (boundaries[it]) "|" else " " }))
append(row("addr", samples.joinToString("") {
if (it.address % 1.0 > 1e-6) "~" else (it.address.toInt() % 10).toString()
}))
append(row("phase", samples.joinToString("") { level(it.stagePhase) }))
append(row("value", samples.joinToString("") { level(it.value) }))
append(row("pulse", (0 until columns).joinToString("") {
if (boundaries[it] && samples[it].label.isNotEmpty()) "^" else " "
}))
}
}
private fun level(v: Double): String {
val clamped = v.coerceIn(0.0, 1.0)
return LEVELS[(clamped * (LEVELS.length - 1)).roundToInt()].toString()
}
}