Skip to content

BooleanNetworkSoundDocs.kt

src/jvmMain/kotlin/afield/audio/synth/BooleanNetworkSoundDocs.kt · 412 lines

@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth

import afield.audio.engine.WavFloatArrayWriter
import afield.simulation.automata.BoolNode
import afield.simulation.automata.BooleanNetwork
import afield.simulation.automata.findAttractors
import afield.simulation.automata.kauffmanNetwork
import afield.simulation.automata.repeatedCycle
import java.io.File
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.pow
import kotlin.math.sin

FIELD_TEST id: synth.boolean.network.sound runner: jvm-main entrypoint: afield.audio.synth.BooleanNetworkSoundDocs tags: [smoke, synth, audio, wav, dirt, docs, capture, cpu] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "boolean network sound docs rendered"

The clips on boolean_network_music_examples.md: each figure's note stream played through the pinned Tidal Dirt-Samples.

BooleanNetworkSoundDocs rebuilds the notes each page fence builds and renders them with patRenderStereoDirt. It runs as the synth.boolean.network.sound FIELD_TEST.

object BooleanNetworkSoundDocs

The clips on boolean_network_music_examples.md: each figure's note stream, played through the real Tidal Dirt-Samples.

Every scene rebuilds the notes its page fence builds — the same seeds, step durations, trigger modes and labels — so a clip is the piano roll above it, heard. The notes become Dirt events by notesToDirtPattern: a note's start is the event onset, its velocity the gain, and its label the sample token, which for three of the four fences already is a Dirt token (bd:3, sn:1, hh:2, …). The other two need one decision each, made in Scene.token:

  • the first fence labels its notes with pitch names (C2 … C4). Those play as Tidal plays a pitched sample, arpy with speed = 2^(semitones/12), counting from C3, so the five lanes are five pitches of one sample rather than five drums;
  • the driven fence's forced lane is labelled clk, which no Dirt folder is called. It plays as click, the nearest folder that is one.

Rendering is patRenderStereoDirt with strict = true against DirtRender.requirePinnedLibrary, so there is no synth voice anywhere here: a missing library, or a token the pinned subset does not hold, is an exception that names ./gradlew fetchDocsDirtSamples.

Run as the synth.boolean.network.sound FIELD_TEST, every scene renders to a temporary directory and is checked — frame count, level, and that every scheduled event was mixed from a real sample — and nothing committed is written. Pass an output directory as args[0] to regenerate the page's .wavs.

object BooleanNetworkSoundDocs {

class Scene

One page figure as sound: the notes it draws, how long it lasts, and the token per note.

    class Scene(
        val name: String,
        /** What to listen for, printed by the smoke. */
        val listenFor: String,
        val seconds: Double,
        val notes: () -> List<ScheduledNote>,
        val token: (ScheduledNote) -> String = { requireNotNull(it.label) { "note $it has no label to play" } },
        val speed: (ScheduledNote) -> Double = { 1.0 },
        /** Notes used for the mix's transient diagnostic; overlapping samples can mask a hit. */
        val mustHear: (ScheduledNote) -> Boolean = { true },
        /** Why the transient diagnostic uses only some notes, when it does. */
        val mustHearWhy: String = "every note",
    )

the four fences, rebuilt

    private fun <T> Iterator<T>.collectAll(): List<T> = buildList { while (hasNext()) add(next()) }

fun attractorNotes(): List<ScheduledNote>

The first fence: the first kauffmanNetwork(5, 2) seed with an attractor of period 6 or more.

    fun attractorNotes(): List<ScheduledNote> {
        val (seed, net, attr) = (0..80).firstNotNullOf { seed ->
            val net = kauffmanNetwork(n = 5, k = 2, seed = seed.toLong())
            net.findAttractors().filter { it.period >= 6 }.maxByOrNull { it.period }?.let { Triple(seed, net, it) }
        }
        check(seed == 4 && attr.period == 6) {
            "the page's first figure is headed 'seed 4 · period 6'; the sweep now finds seed $seed, period ${attr.period}"
        }
        val pitches = listOf(36, 43, 48, 55, 60)
        val labels = listOf("C2", "G2", "C3", "G3", "C4")
        return net.scheduledNotes(initialState = attr.cycle.first(), stepDuration = 0.25, steps = 24) { step ->
            step.lanes(BooleanTriggerMode.RISING_EDGE).map { lane ->
                note(
                    pitch = pitches[lane], duration = step.duration * 0.84, start = step.start,
                    velocity = 0.74 + lane * 0.04, label = labels[lane],
                )
            }
        }.collectAll()
    }

fun dirtTokenNotes(): List<ScheduledNote>

The second fence: seed 27's longest attractor, its lanes labelled with Dirt tokens.

    fun dirtTokenNotes(): List<ScheduledNote> {
        val net = kauffmanNetwork(n = 5, k = 2, seed = 27L)
        val attr = net.findAttractors().maxByOrNull { it.period }!!
        val tokens = listOf("bd:3", "sn:1", "hh:4", "cp:0", "perc:5")
        return net.scheduledNotes(initialState = attr.cycle.first(), stepDuration = 0.25, steps = 32) { step ->
            step.lanes(BooleanTriggerMode.RISING_EDGE).map { lane ->
                note(
                    pitch = 42 + lane * 3, duration = step.duration * 0.8, start = step.start,
                    velocity = if (lane == 0) 1.0 else 0.82, label = tokens[lane],
                )
            }
        }.collectAll()
    }

fun braidNotes(): List<ScheduledNote>

The third fence: three attractors on three clocks, braided.

    fun braidNotes(): List<ScheduledNote> {
        class Layer(
            val seed: Long, val stepDuration: Double, val steps: Int, val mode: BooleanTriggerMode,
            val basePitch: Int, val label: String, val velocity: Double,
        )
        val layers = listOf(
            Layer(27L, 0.125, 64, BooleanTriggerMode.RISING_EDGE, 36, "bd", 0.98),
            Layer(11L, 0.1875, 48, BooleanTriggerMode.CHANGE, 50, "sn", 0.76),
            Layer(42L, 0.0625, 128, BooleanTriggerMode.ACTIVE, 74, "hh", 0.42),
        )
        return layers.flatMap { layer ->
            val (net, attr) = (layer.seed until layer.seed + 80).firstNotNullOf { seed ->
                val net = kauffmanNetwork(n = 5, k = 2, seed = seed)
                net.findAttractors().filter { it.period >= 4 }.maxByOrNull { it.period }?.let { net to it }
            }
            net.scheduledNotes(initialState = attr.cycle.first(), stepDuration = layer.stepDuration, steps = layer.steps) { step ->
                val lanes = when (layer.mode) {
                    BooleanTriggerMode.ACTIVE ->
                        step.state.indices.filter { lane -> step.isActive(lane) && (lane + step.stepIndex.toInt()) % 2 == 0 }.take(2)
                    else -> step.lanes(layer.mode)
                }
                lanes.map { lane ->
                    note(
                        pitch = layer.basePitch + lane * 2,
                        duration = if (layer.mode == BooleanTriggerMode.ACTIVE) step.duration * 0.42 else step.duration * 0.78,
                        start = step.start,
                        velocity = (layer.velocity - lane * 0.04).coerceAtLeast(0.2),
                        label = "${layer.label}:$lane",
                    )
                }
            }.collectAll()
        }.sortedWith(compareBy<ScheduledNote> { it.start }.thenBy { it.pitch })
    }

fun drumGridNotes(): List<ScheduledNote>

boolean_networks_examples.md's drum-grid fence: the same rhythm candidate, the same step % 4 gating into kick / snare / hat / clap / percussion lanes, played at eight steps a second for two passes of the 16-step grid the figure draws.

    fun drumGridNotes(): List<ScheduledNote> {
        class Candidate(val net: BooleanNetwork, val attr: afield.simulation.automata.Attractor, val score: Double)
        fun activity(attr: afield.simulation.automata.Attractor, net: BooleanNetwork): Double =
            attr.cycle.sumOf { state -> state.count { it } }.toDouble() / (attr.period * net.size).toDouble()
        val candidate = (0..240).mapNotNull { seed ->
            val net = kauffmanNetwork(n = 5, k = 2, seed = seed.toLong())
            net.findAttractors().filter { it.period in 5..10 }.map { attr ->
                val density = activity(attr, net)
                val periodScore = 1.0 - kotlin.math.abs(attr.period - 8) / 8.0
                val densityScore = 1.0 - kotlin.math.abs(density - 0.42)
                val basinScore = attr.basin.size.toDouble() / (1 shl net.size).toDouble()
                Candidate(net, attr, periodScore * 1.6 + densityScore * 1.2 + basinScore)
            }.maxByOrNull { it.score }
        }.maxByOrNull { it.score }!!
        val natural = candidate.attr.repeatedCycle(16)
        val gated = natural.mapIndexed { step, state ->
            booleanArrayOf(
                state[0] && step % 4 == 0,
                state[1] && step % 4 == 2,
                state[2] && step % 2 == 0,
                state[3] && (step % 8 == 4 || step % 8 == 12),
                state[4] && (step % 4 == 1 || step % 4 == 3),
            )
        }
        val tokens = listOf("bd", "sn", "hh", "cp", "perc")
        val stepSeconds = 0.125
        return (0 until 2).flatMap { pass ->
            gated.flatMapIndexed { step, lanes ->
                lanes.indices.filter { lanes[it] }.map { lane ->
                    ScheduledNote(
                        start = (pass * gated.size + step) * stepSeconds,
                        duration = stepSeconds * 0.8,
                        pitch = (36 + lane * 5).toFloat(),
                        velocity = if (lane == 2) 0.6f else 0.9f,
                        label = tokens[lane],
                    )
                }
            }
        }
    }

fun drivenNotes(): List<ScheduledNote>

The fourth fence: a hand-built network with node 0 forced by a sine-derived square wave.

    fun drivenNotes(): List<ScheduledNote> {
        val net = BooleanNetwork(
            listOf(
                BoolNode("clk", "external", emptyList()) { s -> s[0] },
                BoolNode("kick", "clk XOR clap", listOf(0, 3)) { s -> s[0] xor s[3] },
                BoolNode("snare", "kick OR hat", listOf(1, 4)) { s -> s[1] || s[4] },
                BoolNode("clap", "snare AND NOT clk", listOf(2, 0)) { s -> s[2] && !s[0] },
                BoolNode("hat", "kick XOR snare", listOf(1, 2)) { s -> s[1] xor s[2] },
            )
        )
        val driver = { stepIndex: Long, _: Double, _: Double, state: BooleanArray ->
            state[0] = sin(2.0 * PI * stepIndex.toDouble() / 12.0) > 0.0
        }
        val labels = listOf("clk", "bd", "sn", "cp", "hh")
        val pitches = listOf(30, 36, 50, 57, 74)
        return net.scheduledDrivenNotes(
            initialState = BooleanArray(net.size), stepDuration = 0.125, steps = 64, driver = driver,
        ) { step ->
            step.lanes(BooleanTriggerMode.RISING_EDGE).map { lane ->
                note(
                    pitch = pitches[lane],
                    duration = if (lane == 4) step.duration * 0.45 else step.duration * 0.9,
                    start = step.start,
                    velocity = if (lane == 0) 0.4 else 0.78 + lane * 0.04,
                    label = labels[lane],
                )
            }
        }.collectAll()
    }

val SCENES: List<Scene>

body — 44 lines, from line 231
    val SCENES: List<Scene> = listOf(
        Scene(
            name = "attractor-roll",
            listenFor = "a period-6 figure repeating every 1.5 s, four pitches of one sample",
            seconds = 6.0,
            notes = ::attractorNotes,
            token = { "arpy" },
            // Tidal's pitched-sample convention: speed 2^(semitones/12) from the sample's own pitch,
            // which is taken to be C3 (MIDI 48), the middle of the five.
            speed = { 2.0.pow((it.pitch - 48.0) / 12.0) },
        ),
        Scene(
            name = "dirt-tokens",
            listenFor = "a period-2 attractor: hats and kick on every step, one perc hit at the start",
            seconds = 8.0,
            notes = ::dirtTokenNotes,
        ),
        Scene(
            name = "braid",
            listenFor = "kick, snare and shimmer all running the full eight seconds",
            seconds = 8.0,
            notes = ::braidNotes,
            // The page's shimmer tokens are hh:0 … hh:4, and in Dirt-Samples hh:1 to hh:4 are a
            // crash and three long hits of up to 2.4 s. Sixteen a second of those is a wash, and a
            // quiet sixteenth under it does not start a new transient. The kick and snare do.
            mustHear = { !it.label.orEmpty().startsWith("hh") },
            mustHearWhy = "kick and snare; the hh shimmer is a wash of long crash samples",
        ),
        Scene(
            name = "drum-grid",
            listenFor = "the gated attractor: kick on the beat, snare on the off-beat, hats in eighths, two bars twice",
            seconds = 4.0,
            notes = ::drumGridNotes,
        ),
        Scene(
            name = "driven",
            listenFor = "the forced clock lane as a quiet click, the network answering it",
            seconds = 8.0,
            notes = ::drivenNotes,
            token = { if (it.label == "clk") "click" else requireNotNull(it.label) },
        ),
    )

    private val byName = SCENES.associateBy { it.name }

fun scene(name: String): Scene

Looks a scene up by name, with a message that lists the alternatives.

    fun scene(name: String): Scene =
        byName[name] ?: error("unknown BooleanNetworkSoundDocs scene '$name'; known: ${byName.keys.sorted()}")

notes to Dirt events

fun notesToDirtPattern

notes as a one-cycle-per-second pattern of Dirt tokens, ready for patRenderStereoDirt at cps = 1.

Each note becomes one event with an onset at its start, valued token, annotated with its velocity as gain and speed as speed. The event's length is the note's, though a Dirt sample is a one-shot and plays to its end regardless.

    fun notesToDirtPattern(
        notes: List<ScheduledNote>,
        token: (ScheduledNote) -> String,
        speed: (ScheduledNote) -> Double = { 1.0 },
    ): PatPattern<String> {
        val events = notes.map { n ->
            val arc = PatArc(PatQ.fromDouble(n.start), PatQ.fromDouble(n.start + maxOf(n.duration, 1e-3)))
            PatEvent(
                arc = arc,
                value = token(n),
                annotations = mapOf("gain" to n.velocity.toString(), "speed" to speed(n).toString()),
            )
        }
        return PatPattern { arc -> events.filter { it.arc.start >= arc.start && it.arc.start < arc.end } }
    }

class Rendered(val scene: Scene, val notes: List<ScheduledNote>, val result: DirtRender.DirtRenderResult)

One scene rendered: the audio, and the Dirt bookkeeping that says what played.

    class Rendered(val scene: Scene, val notes: List<ScheduledNote>, val result: DirtRender.DirtRenderResult)

fun render(name: String): Rendered

Renders name through the pinned Dirt-Samples, strictly.

    fun render(name: String): Rendered {
        val scene = scene(name)
        val notes = scene.notes()
        check(notes.isNotEmpty()) { "scene '$name' produced no notes" }
        val pattern = notesToDirtPattern(notes, scene.token, scene.speed)
        val result = patRenderStereoDirt(
            pattern,
            cycles = ceil(scene.seconds).toInt(),
            cps = 1.0,
            library = DirtRender.requirePinnedLibrary(),
            strict = true,
        )
        return Rendered(scene, notes, result)
    }

fun renderWav(name: String, outPath: String)

Offline-renders scene name to a stereo 16-bit WAV at outPath.

The renderWav(name, path) contract the docs runner's audio="…" fence drives, and the one DocsAudioProvenance re-renders through to check the committed clip.

    fun renderWav(name: String, outPath: String) {
        val audio = render(name).result.audio
        require(audio.frames > 0) { "scene '$name' rendered no samples" }
        require(audio.left.any { it != 0f } || audio.right.any { it != 0f }) { "scene '$name' rendered silence" }
        WavFloatArrayWriter.writeStereo16(outPath, audio.left, audio.right, PatSoundDocs.SR, normalizePeak = 0.9f)
    }

a listen check that does not need ears

fun audibleAt(mono: FloatArray, seconds: Double, sampleRate: Int = PatSoundDocs.SR, ratio: Double = 1.5): Boolean

Whether a hit starts at seconds in mono: the first-difference energy of the 10 ms after it is more than ratio times that of the 10 ms before it.

First differences rather than plain energy detect a burst of high-frequency content under a kick that is still booming. This is a mix diagnostic: overlapping one-shots can obscure a real new sample, so DirtRender.DirtRenderResult.renderedEvents checks event delivery.

    fun audibleAt(mono: FloatArray, seconds: Double, sampleRate: Int = PatSoundDocs.SR, ratio: Double = 1.5): Boolean {
        val t = Math.round(seconds * sampleRate).toInt()
        val w = sampleRate / 100
        fun energy(from: Int, to: Int): Double {
            var e = 0.0
            for (i in maxOf(1, from) until minOf(mono.size, to)) {
                val d = mono[i] - mono[i - 1]
                e += d.toDouble() * d
            }
            return e / w
        }
        return energy(t, t + w) > ratio * energy(t - w, t) + 1e-12
    }

FIELD_TEST entry point

fun main(args: Array<String>)

body — 48 lines, from line 364
    @JvmStatic
    fun main(args: Array<String>) {
        val outDir = File(
            args.getOrNull(0) ?: System.getenv("BOOLEAN_NETWORK_SOUND_DIR")
                ?: File(System.getProperty("java.io.tmpdir"), "boolean-network-sound").path,
        )
        outDir.mkdirs()

        SCENES.forEach { scene ->
            val rendered = render(scene.name)
            val audio = rendered.result.audio
            check(audio.frames > 0) { "${scene.name}: rendered no samples" }
            check(audio.left.all { it.isFinite() } && audio.right.all { it.isFinite() }) { "${scene.name}: non-finite samples" }
            val expectedFrames = (ceil(scene.seconds) * PatSoundDocs.SR).toInt()
            check(audio.frames == expectedFrames) { "${scene.name}: expected $expectedFrames frames, got ${audio.frames}" }
            val peak = PatSoundDocs.peak(audio.mono)
            val rms = PatSoundDocs.rms(audio.mono)
            check(peak > 0.02) { "${scene.name}: effectively silent (peak=$peak)" }
            check(peak <= 8.0) { "${scene.name}: runaway level (peak=$peak)" }
            check(rms > 0.002) { "${scene.name}: RMS too low to be music (rms=$rms)" }
            check(rendered.result.usedSamples && rendered.result.unmatched.isEmpty()) {
                "${scene.name}: ${rendered.result.describe()}"
            }
            val notesInClip = rendered.notes.count { it.start < scene.seconds }
            check(rendered.result.renderedEvents == notesInClip) {
                "${scene.name}: mixed ${rendered.result.renderedEvents} of $notesInClip scheduled sample events inside the clip"
            }

            val onsetTimes = rendered.notes.map { it.start }.distinct().filter { it < scene.seconds }
            val heard = onsetTimes.count { audibleAt(audio.mono, it) }
            val mustHear = rendered.notes.filter(scene.mustHear).map { it.start }.distinct().filter { it < scene.seconds }
            val missed = mustHear.filterNot { audibleAt(audio.mono, it) }
            println(
                "scene ${scene.name}: %.2fs, %d notes at %d onset times, a hit starts at %d of them, peak=%.3f rms=%.4f; %s"
                    .format(audio.frames.toDouble() / PatSoundDocs.SR, rendered.notes.size, onsetTimes.size, heard, peak, rms,
                        rendered.result.describe())
            )
            println("  detectable transients: ${mustHear.size - missed.size} of ${mustHear.size} (${scene.mustHearWhy})")
            println("  listen for: ${scene.listenFor}")

            val file = File(outDir, "${scene.name}.wav")
            WavFloatArrayWriter.writeStereo16(file, audio.left, audio.right, PatSoundDocs.SR, normalizePeak = 0.9f)
            println("  wrote ${file.absolutePath} (${file.length()} bytes)")
        }
        // After every scene is written, so a failure leaves every clip on disk to listen to.
        println("boolean network sound docs rendered: ${SCENES.size} scene(s)")
    }
}