Skip to content

DoughLive.kt

src/jvmMain/kotlin/afield/audio/synth/DoughLive.kt · 485 lines

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

import afield.document.graph.Box
import afield.runtime.lifecycle.AnimationClock
import afield.app.diagnostics.logging.lprintln
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.roundToLong
import kotlin.math.sqrt

Box-level dough: sound("/dough/play/note/60/s/sine/...") from any box, and thisRMS, the level that box's own sounds are making right now.

The paths are dough's, unchanged, so a path copied from a browser demo running dough.wasm plays the same here: DoughEngine runs the same module. One DoughLive serves the whole process. It builds its engine on its own render thread (a DoughEngine admits only the thread that built it) and passes audio to FieldAudioEngine through a short PatSpatialRing, so nothing dough does, booting included, happens on the audio thread.

Every box gets a dough group number, and Box.sound tags the box's events with it. After every block the render thread reads each sounding voice's group and output sample out of the module's memory (DoughEngine.voiceFrames) and keeps a running mean square per group, so Box.thisRMS is measured from what the box's voices actually produced, not estimated from the numbers in its paths.

class DoughLive internal constructor

The process-wide realtime dough engine behind Box.sound and Box.thisRMS.

play queues an event from any thread; the render thread applies it before its next block, so a sound starts within one block plus the ring (about 25 ms at the default fill) plus the audio engine's own output queue. level reads the published measurement for a group.

If the audio device stops asking for audio (no output device, or a contended one), the ring fills and the render thread keeps rendering at wall-clock pace and drops the blocks, so levels keep moving and a capture with no speakers still shows a box reacting to its sound.

class DoughLive internal constructor(
    private val wasmPath: Path,
    val sampleRate: Int,
    /** Frames the render thread keeps in the ring: the latency it adds. */
    val targetFillFrames: Int = 1024,
    /** Time constant of the level meter, in seconds. */
    val levelTimeConstant: Double = 0.04,
) : AutoCloseable {

companion object

    companion object {

const val SINE_GAIN_ONE_RMS = 0.139

What level divides by: the RMS of one voice's output for /dough/play/s/sine/gain/1, centre-panned, as DoughVoiceMeter measures it. So a sine at gain 1 reads 1, and a gain of 0.2 reads about 0.2, which is the scale the madp demos' thisRMS * 18 was written for. Pinned by DoughLiveTest.

        @Volatile
        private var shared: DoughLive? = null

fun sharedOrNull(): DoughLive? = shared

The process's DoughLive if something has already started it, without starting one.

fun shared(): DoughLive

The process's DoughLive, started on first use and registered with audioEngine.

        @Synchronized
        fun shared(): DoughLive = shared ?: run {
            val engine = audioEngine()
            DoughLive(doughWasmPath(), engine.sampleRate).also { live ->
                live.start()
                val bank = PatSpatialBank(live.ring, live.sampleRate)
                bank.isReady = { live.ready }
                engine.register(audio(bank, gain = 1f))
                shared = live
            }
        }

        private val groups = WeakHashMap<Box, Int>()
        private val nextGroup = AtomicInteger(1)

fun groupOf(box: Box): Int

The dough group this box's sounds carry. Stable for the life of the box in this process.

        @Synchronized
        fun groupOf(box: Box): Int = groups.getOrPut(box) { nextGroup.getAndIncrement() }
    }

val ring = PatSpatialRing(targetFillFrames * 2)

The ring the render thread fills and the audio thread drains.

var ready: Boolean

True once the engine has booted on the render thread.

    @Volatile
    var ready: Boolean = false
        private set

var lastError: Throwable?

The last error the render thread hit, from booting or from an event the module refused.

    @Volatile
    var lastError: Throwable? = null
        private set

var droppedBlocks: Long

Blocks rendered and dropped because nothing was draining the ring.

    @Volatile
    var droppedBlocks: Long = 0L
        private set

    private val meter = DoughVoiceMeter(sampleRate, levelTimeConstant)
    private val pending = ConcurrentLinkedQueue<Map<String, Any?>>()
    private val patterns = CopyOnWriteArrayList<PatternVoice>()

var framesRendered: Long

Frames the render thread has rendered (or, with no device draining it, dropped) since it started.

    @Volatile
    var framesRendered: Long = 0L
        private set

val heardFrame: Long get() = max(0L, framesRendered - ring.fill)

The frame that is being heard now: framesRendered less what is still waiting in the ring.

class PatternVoice internal constructor

A pattern play is playing. stop ends it after the block in progress; notes already started finish their own envelopes.

    class PatternVoice internal constructor(
        private val live: DoughLive,
        val pattern: PatPattern<String>,
        /** Cycles per second. */
        val cps: Double,
        /** The frame on [DoughLive.framesRendered]'s clock at which cycle 0 begins. */
        val originFrame: Long,
        /** The dough `group` its events carry, or null for none. */
        val group: Int?,
        /** What every event's `gain` (1 where it has none) is multiplied by. */
        val gain: Double = 1.0,
    ) {

var stopped

        @Volatile
        var stopped = false
            private set

var lastError: Throwable?

The last exception a query of pattern threw (a signal { } that failed, say), or null.

        @Volatile
        var lastError: Throwable? = null
            internal set

val cycle: Double get()

Where this pattern is, in cycles, as heard now.

        val cycle: Double get() = max(0.0, (live.heardFrame - originFrame).toDouble() / live.sampleRate * cps)

fun stop()

        fun stop() {
            stopped = true
            live.patterns.remove(this)
        }

        private val cpsQ = PatQ.of((cps * 1_000_000).roundToLong(), 1_000_000L)

internal fun eventsInBlock(firstFrame: Long, frames: Int): List<Map<String, Any?>>

        internal fun eventsInBlock(firstFrame: Long, frames: Int): List<Map<String, Any?>> {
            val a = firstFrame - originFrame
            val b = a + frames
            if (b <= 0) return emptyList()
            val arc = PatArc(PatQ.of(max(0L, a), live.sampleRate.toLong()) * cpsQ, PatQ.of(b, live.sampleRate.toLong()) * cpsQ)
            // A continuous pattern (no `whole`) has no notes to strike, so only events with a start play.
            return pattern.events(arc).filter { it.whole != null && it.hasOnset }.map { event ->
                val e = doughPatternEvent(event, cps).toMutableMap()
                if (gain != 1.0) e["gain"] = patFormatNumber(((e["gain"] as? String)?.toDoubleOrNull() ?: 1.0) * gain)
                if (group != null && "group" !in e) e["group"] = group
                e
            }
        }
    }

fun play(pattern: PatPattern<String>, cps: Double = 0.5, group: Int? = null, originFrame: Long = framesRendered, gain: Double = 1.0): PatternVoice

Play pattern at cps cycles per second until PatternVoice.stop, from cycle 0 at originFrame (now, by default; pass another voice's originFrame to play in step with it).

The pattern is queried on the render thread one 128-frame block at a time, so onsets land on a block (2.7 ms at 48 kHz) and a continuous parameter, gain(sig { level }) for instance, is read as each event is scheduled. Each event becomes a dough event by doughPatternEvent. A query that throws is recorded in PatternVoice.lastError and that block of the pattern is skipped. gain scales every event's own gain, to set the level of a whole pattern written for another synthesiser.

    fun play(pattern: PatPattern<String>, cps: Double = 0.5, group: Int? = null, originFrame: Long = framesRendered, gain: Double = 1.0): PatternVoice {
        require(cps > 0.0) { "cps must be > 0, got $cps" }
        return PatternVoice(this, pattern, cps, originFrame, group, gain).also { patterns.add(it) }
    }
    @Volatile
    private var stopped = false
    private var thread: Thread? = null

fun play(path: String, group: Int? = null)

Queue one dough path, tagged with group when it is not null.

The path is parsed here, on the caller's thread, so a malformed one fails in the box that wrote it instead of on the render thread.

    fun play(path: String, group: Int? = null) {
        val event: Map<String, Any?> = doughPathEvent(path)
        pending.add(if (group == null || "group" in event) event else event + ("group" to group))
    }

fun level(group: Int): Double = sqrt(meter.published[group] ?: 0.0) / SINE_GAIN_ONE_RMS

The measured level of group's voices, scaled so a sine at gain 1 reads about 1.

val mixLevel: Double get() = sqrt(meter.publishedTotal) / SINE_GAIN_ONE_RMS

The measured level of every voice together, on the same scale as level.

internal fun start()

body — 91 lines, from line 207
    internal fun start() {
        thread = Thread({ renderLoop() }, "DoughLive").apply {
            isDaemon = true
            start()
        }
    }

    private fun renderLoop() {
        val engine = try {
            DoughEngine(wasmPath, sampleRate)
        } catch (t: Throwable) {
            lastError = t
            lprintln("error,audio", "DoughLive could not boot dough.wasm: $t")
            return
        }
        try {
            ready = true
            val l = FloatArray(DoughEngine.BLOCK_SIZE)
            val r = FloatArray(DoughEngine.BLOCK_SIZE)
            val blockSeconds = DoughEngine.BLOCK_SIZE.toDouble() / sampleRate
            var lastProgress = System.nanoTime()
            var stallClock = 0.0
            while (!stopped) {
                drain(engine)
                if (ring.fill < targetFillFrames && ring.room >= DoughEngine.BLOCK_SIZE) {
                    schedulePatterns(engine)
                    engine.renderBlock(l, r)
                    framesRendered += DoughEngine.BLOCK_SIZE
                    meter.afterBlock(engine)
                    ring.write(l, r, DoughEngine.BLOCK_SIZE)
                    lastProgress = System.nanoTime()
                    stallClock = 0.0
                    continue
                }
                val now = System.nanoTime()
                val stalledFor = (now - lastProgress) / 1e9
                if (stalledFor > 0.25) {
                    // Nobody is draining the ring. Keep the engine's clock and the meters moving at
                    // the pace a device would have, and drop what it renders.
                    while (stallClock < stalledFor - 0.25) {
                        schedulePatterns(engine)
                        engine.renderBlock(l, r)
                        framesRendered += DoughEngine.BLOCK_SIZE
                        meter.afterBlock(engine)
                        droppedBlocks++
                        stallClock += blockSeconds
                    }
                }
                try {
                    Thread.sleep(1L)
                } catch (_: InterruptedException) {
                    break
                }
            }
        } finally {
            ready = false
            engine.close()
        }
    }

    private fun schedulePatterns(engine: DoughEngine) {
        for (voice in patterns) {
            if (voice.stopped) continue
            val events = try {
                voice.eventsInBlock(framesRendered, DoughEngine.BLOCK_SIZE)
            } catch (t: Throwable) {
                voice.lastError = t
                continue
            }
            for (event in events) {
                try {
                    engine.evaluate(event)
                } catch (t: Throwable) {
                    lastError = t
                    lprintln("warning,audio", "dough refused $event: $t")
                }
            }
        }
    }

    private fun drain(engine: DoughEngine) {
        while (true) {
            val event = pending.poll() ?: return
            try {
                engine.evaluate(event)
            } catch (t: Throwable) {
                lastError = t
                lprintln("warning,audio", "dough refused $event: $t")
            }
        }
    }

override fun close()

    override fun close() {
        stopped = true
        thread?.join(2_000)
    }
}

class DoughVoiceMeter(sampleRate: Int, val timeConstant: Double = 0.04, private val publishEvery: Int = 4)

Per-group running mean square of a DoughEngine's voices, one sample per voice per block.

afterBlock belongs to the thread that renders the engine. It sums each group's voices' squared output samples, folds that into a one-pole average with time constant timeConstant, and every few blocks publishes a snapshot that any thread may read through published and publishedTotal. A group whose level has decayed to nothing is forgotten. Reverb and delay are mixed after the voices in dough, so they are not part of any group's level.

class DoughVoiceMeter(sampleRate: Int, val timeConstant: Double = 0.04, private val publishEvery: Int = 4) {
    private val alpha = 1.0 - exp(-DoughEngine.BLOCK_SIZE / (sampleRate * timeConstant))
    private val frames = FloatArray(DoughEngine.MAX_VOICES * 3)
    private val meanSquare = HashMap<Int, Double>()
    private val thisBlock = HashMap<Int, Double>()
    private var total = 0.0
    private var blocks = 0

var published: Map<Int, Double>

Mean square per group, as of the last publish.

    @Volatile
    var published: Map<Int, Double> = emptyMap()
        private set

var publishedTotal: Double

Mean square of every voice together, as of the last publish.

    @Volatile
    var publishedTotal: Double = 0.0
        private set

fun afterBlock(engine: DoughEngine)

Fold the engine's current voices in. Call once after every DoughEngine.renderBlock.

    fun afterBlock(engine: DoughEngine) {
        val n = engine.voiceFrames(frames)
        thisBlock.clear()
        var sum = 0.0
        for (v in 0 until n) {
            val group = frames[v * 3].toInt()
            val left = frames[v * 3 + 1].toDouble()
            val right = frames[v * 3 + 2].toDouble()
            val square = 0.5 * (left * left + right * right)
            if (!square.isFinite()) continue
            thisBlock[group] = (thisBlock[group] ?: 0.0) + square
            sum += square
        }
        for (group in thisBlock.keys) meanSquare.putIfAbsent(group, 0.0)
        val each = meanSquare.entries.iterator()
        while (each.hasNext()) {
            val entry = each.next()
            val next = entry.value + alpha * ((thisBlock[entry.key] ?: 0.0) - entry.value)
            if (next < 1e-12 && entry.key !in thisBlock) each.remove() else entry.setValue(next)
        }
        total += alpha * (sum - total)
        if (++blocks % publishEvery == 0) {
            published = HashMap(meanSquare)
            publishedTotal = total
        }
    }
}

fun doughPathEvent(path: String): Map<String, String>

A dough path as the key/value event the module reads: "/dough/play/s/saw/n/60" is {dough=play, s=saw, n=60}.

Line breaks are allowed and anything after // on a line is dropped. Fails on an odd number of segments rather than guessing which value is missing.

fun doughPathEvent(path: String): Map<String, String> {
    val chunks = path
        .trim()
        .lines()
        .map { it.substringBefore("//") }
        .joinToString("")
        .split("/")
        .filter { it.isNotBlank() }

    require(chunks.size % 2 == 0) { "Expected key/value pairs in path: $path" }

    val out = linkedMapOf<String, String>()
    var i = 0
    while (i < chunks.size) {
        out[chunks[i].trim()] = chunks[i + 1].trim()
        i += 2
    }
    return out
}

fun doughPatternEvent(event: PatEvent<String>, cps: Double): Map<String, Any?>

A pattern event as the dough event that plays it, for patterns written the Tidal/Strudel way: the event's value names a note or a sound, and its annotations are dough parameters.

A value that reads as a note (patNoteNumber: 60, c5, ef4) becomes note, played on the event's s (or sound), triangle by default as in Strudel; any other word is the sound itself, so pat("bd hh cp") plays dough's own drums. note/n annotations win over the value and may be note names too. duration is the event's length in seconds at cps, times sustain if given. Every other annotation (gain, pan from 0 left to 1 right, lpf, room, speed, ...) goes to dough verbatim.

fun doughPatternEvent(event: PatEvent<String>, cps: Double): Map<String, Any?> {
    val ann = event.annotations
    val out = linkedMapOf<String, Any?>("dough" to "play")
    val atom = event.value.trim()
    val atomNote = patNoteNumber(atom)
    if (atomNote != null) {
        out["s"] = ann["s"] ?: ann["sound"] ?: "triangle"
        out["note"] = atomNote.toString()
    } else {
        out["s"] = atom.ifEmpty { ann["s"] ?: ann["sound"] ?: "triangle" }
    }
    (ann["note"] ?: ann["n"])?.let { v -> out["note"] = patNoteNumber(v)?.toString() ?: v }
    val whole = event.extent
    val seconds = (whole.end - whole.start).toDouble() / cps
    out["duration"] = patFormatNumber(seconds * (ann["sustain"]?.toDoubleOrNull() ?: 1.0))
    for ((k, v) in ann) {
        if (k in out || k == "sound" || k == "n" || k == "sustain") continue
        out[k] = v
    }
    return out
}

fun doughWasmPath(): Path

Where dough.wasm is: the checkout's copy when Field runs from the repository root, otherwise the classpath resource, copied out to a temporary file once.

fun doughWasmPath(): Path {
    val inTree = Paths.get("./src/jvmMain/resources/dough.wasm")
    if (Files.isRegularFile(inTree)) return inTree
    val resource = DoughEngine::class.java.getResourceAsStream("/dough.wasm")
        ?: error("dough.wasm is neither at $inTree nor on the classpath")
    val copy = Files.createTempFile("dough", ".wasm")
    resource.use { Files.copy(it, copy, java.nio.file.StandardCopyOption.REPLACE_EXISTING) }
    copy.toFile().deleteOnExit()
    return copy
}

private class BoxSoundState {
    val lastPlayed = HashMap<String, Double>()
}

private val soundStates = WeakHashMap<Box, BoxSoundState>()

fun Box.sound(path: String, key: String = path, minInterval: Double = 0.125, force: Boolean = false): Boolean

Play a dough path from this box, unless the same key played less than minInterval seconds ago.

if (justEntered) sound("/dough/play/note/60/duration/0.08/s/sine/gain/0.2/lpf/1200/release/0.05")

The path is dough's own (note/n, s/sound, gain, lpf, release, verb, pan where 0 is hard left, ...), so a path that plays in a browser running dough.wasm plays the same here. key defaults to the path itself, so calling this every frame with the same path sounds at most once per minInterval; give two different calls the same key to make them share the limit. force ignores the limit. Returns whether it played.

The event is tagged with this box's dough group, which is what lets thisRMS measure the box's own voices; a path that sets its own group keeps it, and then is not counted in thisRMS. The first call in a session boots the engine, which takes a few hundred milliseconds on its own thread.

fun Box.sound(path: String, key: String = path, minInterval: Double = 0.125, force: Boolean = false): Boolean {
    val now = AnimationClock.nowSeconds()
    val state = synchronized(soundStates) { soundStates.getOrPut(this) { BoxSoundState() } }
    if (!force) {
        val previous = state.lastPlayed[key]
        if (previous != null && now - previous < minInterval) return false
    }
    DoughLive.shared().play(path, DoughLive.groupOf(this))
    state.lastPlayed[key] = now
    return true
}

fun Box.soundNow(path: String): Boolean = sound(path, force = true, minInterval = 0.0)

Play a dough path from this box now, whatever played before: sound with force = true.

val Box.thisRMS: Double

How loud this box's own dough voices are right now, measured: about 1 for a sine at gain/1, 0.2 for one at gain/0.2, 0 when nothing this box played is still sounding.

A running RMS over roughly the last 40 ms of the voices sound started from this box, sampled once per 128-frame block, so it rises within a few milliseconds of a note and falls with its release. Excludes the shared reverb and delay. Reads 0 before this box has played anything.

val Box.thisRMS: Double
    get() = DoughLive.sharedOrNull()?.level(DoughLive.groupOf(this)) ?: 0.0