Skip to content

PdInstance.kt

src/jvmMain/kotlin/afield/audio/pd/PdInstance.kt · 698 lines

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

import afield.audio.synth.Buf
import afield.audio.synth.StereoNode
import afield.audio.synth.SynthContext
import java.io.Closeable
import java.io.File
import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout.JAVA_FLOAT
import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

A running libpd instance as a Field object: PdInstance opens patches, renders them offline or as a PdNode in a synth graph, and passes messages in and out.

class PdInstance

Pure Data inside Field: one running Pd, with its own patches, its own [dac~] and its own [s](../../../../../../reference/source/jvmMain/afield/ui/notation/StrokeSketch.md#L178)/[r](../../../../../../reference/source/jvmMain/afield/lang/rectsl/RectSLSdfLook.md#L169) names.

libpd is Pd's engine with the GUI taken away. A patch is plain text, so it can be written inline in a box (openPatchText) or opened from a .pd file drawn in Pd itself (openPatch):

val pd = PdInstance()
pd.openPatchText("""
#N canvas 0 0 400 300 12;
#X obj 20 20 osc~ 440;
#X obj 20 60 *~ 0.1;
#X obj 20 100 dac~;
#X connect 0 0 1 0;
#X connect 1 0 2 0;
#X connect 1 0 2 1;
""")
val (left, right) = pd.renderOffline(1.0)

One thread renders, every thread may talk

libpd is not thread-safe, and the thread that matters is whichever one calls render — the audio thread when a box is playing, the caller when rendering offline. So:

  • messages to Pd (send, bang, symbol, list, message) from any thread are queued lock-free and delivered by render at the top of the next block, in order. A slider moved on the main thread never touches libpd itself. flush delivers them immediately, for when nothing is rendering.
  • messages from Pd — a [s name] that Field has received, or a print — arrive in libpd's hooks, on the rendering thread, in the middle of a block. There they are only queued. poll hands them to Kotlin callbacks on the thread that calls it; a playing box polls from its main thread loop, so no user lambda ever runs on the audio thread.
  • structural calls (opening and closing patches, arrays) take the same lock render holds, briefly. They are rare, and an array read that could interleave with a block would be torn.

Every libpd call made here first makes this instance libpd's current one, on this thread; that is what the PD_MULTI build requires and why two instances never hear each other.

@param sampleRate Pd's sample rate; osc~ 440 is 440Hz at this rate @param inputs audio inputs Pd's [adc~] sees @param outputs audio outputs Pd's [dac~] has; render takes the first two as left and right, and a mono Pd is heard in both

class PdInstance(
    val sampleRate: Int = 48_000,
    val inputs: Int = 0,
    val outputs: Int = 2,
) : Closeable {

data class Message(val receiver: String, val selector: String, val args: List<Any>)

A message Pd sent to Field. selector is bang, float, symbol, list, or a message name.

    data class Message(val receiver: String, val selector: String, val args: List<Any>) {

val float: Float? get() = args.firstOrNull() as? Float

The first argument as a number, when it is one — the value of a float message.

val symbol: String? get() = args.firstOrNull() as? String

The first argument as a symbol, when it is one.

override fun toString(): String

        override fun toString(): String = when (selector) {
            "bang" -> "$receiver: bang"
            "float" -> "$receiver: ${args.first()}"
            else -> "$receiver: $selector ${args.joinToString(" ")}".trimEnd()
        }
    }

    private val lock = ReentrantLock()
    private val arena: Arena = Arena.ofShared()
    private val handle: MemorySegment
    private var closed = false

val blockSize: Int

Pd renders in blocks of this many frames — 64 in every libpd build.

    private val outgoing = ConcurrentLinkedQueue<(Arena) -> Unit>()
    private val incoming = ConcurrentLinkedQueue<Any>()   // Message or a printed line
    private val receivers = ConcurrentHashMap<String, CopyOnWriteArrayList<(Message) -> Unit>>()
    private val bindings = ConcurrentHashMap<String, MemorySegment>()
    private val printers = CopyOnWriteArrayList<(String) -> Unit>()
    private val lastSeen = ConcurrentHashMap<String, Message>()
    private val printBuffer = StringBuilder()
    private val printLog = ArrayDeque<String>()
    private val openPatches = CopyOnWriteArrayList<PdPatch>()

Rendering scratch: Pd renders whole 64-frame blocks, the caller asks for any number of frames, so the remainder of the last block is kept for the next call.

    private var inBlock: MemorySegment
    private var outBlock: MemorySegment
    private val pendingL: FloatArray
    private val pendingR: FloatArray
    private var pendingAt = 0
    private var pendingCount = 0

var framesRendered: Long

Frames rendered so far; Pd's own clock, in samples.

body — 48 lines, from line 117
    @Volatile
    var framesRendered: Long = 0L
        private set

    private val hooks = object : PdNative.Hooks {
        // These run on the rendering thread, inside libpd. They may only queue — and must not throw,
        // because an exception unwinding through an FFM upcall takes the whole VM down.
        override fun print(s: MemorySegment) = guard {
            synchronized(printBuffer) {
                printBuffer.append(PdNative.cString(s))
                var nl = printBuffer.indexOf("\n")
                while (nl >= 0) {
                    val line = printBuffer.substring(0, nl)
                    printBuffer.delete(0, nl + 1)
                    incoming += PrintedLine(line)
                    nl = printBuffer.indexOf("\n")
                }
            }
        }

        override fun bang(recv: MemorySegment) = guard {
            arrive(Message(PdNative.cString(recv), "bang", emptyList()))
        }

        override fun float(recv: MemorySegment, x: Float) = guard {
            arrive(Message(PdNative.cString(recv), "float", listOf(x)))
        }

        override fun symbol(recv: MemorySegment, s: MemorySegment) = guard {
            arrive(Message(PdNative.cString(recv), "symbol", listOf(PdNative.cString(s))))
        }

        override fun list(recv: MemorySegment, argc: Int, argv: MemorySegment) = guard {
            arrive(Message(PdNative.cString(recv), "list", PdNative.atoms(argc, argv)))
        }

        override fun message(recv: MemorySegment, msg: MemorySegment, argc: Int, argv: MemorySegment) = guard {
            arrive(Message(PdNative.cString(recv), PdNative.cString(msg), PdNative.atoms(argc, argv)))
        }

        override fun noteOn(channel: Int, pitch: Int, velocity: Int) = guard { incoming += Midi(Midi.NOTE_ON, channel, pitch, velocity) }
        override fun controlChange(channel: Int, controller: Int, value: Int) =
            guard { incoming += Midi(Midi.CONTROL_CHANGE, channel, controller, value) }
        override fun programChange(channel: Int, value: Int) = guard { incoming += Midi(Midi.PROGRAM_CHANGE, channel, value, 0) }
        override fun pitchBend(channel: Int, value: Int) = guard { incoming += Midi(Midi.PITCH_BEND, channel, value, 0) }
    }

    private class PrintedLine(val text: String)

data class Midi(val kind: String, val channel: Int, val a: Int, val b: Int)

A MIDI event a patch sent with noteout, ctlout, pgmout or bendout.

channel is 0-based, as libpd numbers them: Pd's [noteout 1] arrives as channel 0. For a note, a is the pitch and b the velocity (0 is a note-off); for a controller, a is the controller number and b its value; for a program change or a bend, a is the value.

companion object

const val NOTE_ON = "noteOn"
const val CONTROL_CHANGE = "controlChange"
const val PROGRAM_CHANGE = "programChange"
const val PITCH_BEND = "pitchBend"
    private val midiListeners = CopyOnWriteArrayList<(Midi) -> Unit>()

fun onMidi(callback: (Midi) -> Unit): Closeable {

Call callback with every MIDI event the patch sends. Delivered by poll.

    fun onMidi(callback: (Midi) -> Unit): Closeable {
        midiListeners += callback
        return Closeable { midiListeners -= callback }
    }

fun noteOn(channel: Int, pitch: Int, velocity: Int)

A note to the patch's notein: 0-based channel, velocity 0 for a note-off. Queued.

    fun noteOn(channel: Int, pitch: Int, velocity: Int) = enqueue { PdNative.noteOn(channel, pitch, velocity) }

fun controlChange(channel: Int, controller: Int, value: Int)

A controller to the patch's ctlin. Queued.

    fun controlChange(channel: Int, controller: Int, value: Int) =
        enqueue { PdNative.controlChange(channel, controller, value) }

fun programChange(channel: Int, value: Int) = enqueue { PdNative.programChange(channel, value) }

A program change to pgmin. Queued.

fun pitchBend(channel: Int, value: Int) = enqueue { PdNative.pitchBend(channel, value) }

A pitch bend, -8192 to 8191, to bendin. Queued.

    private fun arrive(message: Message) {
        lastSeen[message.receiver] = message
        incoming += message
    }

    private inline fun guard(block: () -> Unit) {
        try {
            block()
        } catch (_: Throwable) {
        }
    }

init

    init {
        require(sampleRate > 0) { "sampleRate must be positive" }
        require(outputs in 1..64 && inputs in 0..64) { "channel counts out of range" }
        PdNative.ensureInitialised()
        lock.lock()
        try {
            handle = PdNative.newInstance()
            check(handle != MemorySegment.NULL) {
                "libpd was built without multi-instance support; rebuild it with tools/build_libpd_native.py"
            }
            PdNative.setInstance(handle)
            PdNative.installHooks(hooks, arena)
            PdNative.initAudio(inputs, outputs, sampleRate)
            blockSize = PdNative.blockSize()
            inBlock = arena.allocate(JAVA_FLOAT, (blockSize * maxOf(1, inputs)).toLong())
            outBlock = arena.allocate(JAVA_FLOAT, (blockSize * outputs).toLong())
            // DSP on: without it every [dac~] is silent.
            PdNative.startMessage(1)
            PdNative.addFloat(1f)
            Arena.ofConfined().use { a -> PdNative.finishMessage(a.allocateFrom("pd"), a.allocateFrom("dsp")) }
        } finally {
            lock.unlock()
        }
        pendingL = FloatArray(blockSize)
        pendingR = FloatArray(blockSize)
    }

    private inline fun <T> current(block: () -> T): T = lock.withLock {
        check(!closed) { "this PdInstance is closed" }
        PdNative.setInstance(handle)
        block()
    }

patches

fun openPatch(file: File, source: String? = null): PdPatch

Open a .pd file. Abstractions and soundfiler paths resolve relative to its folder.

Pd reports an object it cannot create ([osc 440] for [osc~ 440]) on its console rather than failing the open; those lines are collected in PdPatch.problems.

    fun openPatch(file: File, source: String? = null): PdPatch {
        val absolute = file.absoluteFile
        require(absolute.isFile) { "no Pd patch at $absolute" }
        val text = source ?: absolute.readText()
        val before = drainPrintsForOpen()
        val patch = current {
            Arena.ofConfined().use { a ->
                val p = PdNative.openFile(a.allocateFrom(absolute.name), a.allocateFrom(absolute.parent ?: "."))
                if (p == MemorySegment.NULL) throw PdPatchError("Pd could not open $absolute", emptyList())
                PdPatch(this, p, PdNative.dollarZero(p), absolute, text)
            }
        }
        val during = drainPrintsForOpen()
        patch.problems = PdPatch.problemsIn(text, during)
        (before + during).forEach { incoming += PrintedLine(it) }
        openPatches += patch
        return patch
    }

fun openPatchText(text: String, name: String = "inline", source: String = text): PdPatch

Open a patch written as text — the contents of a .pd file.

The text is written to a temporary folder and opened from there, because libpd opens files and nothing else. A leading blank line and common indentation are removed, so a """ literal can sit indented in a box. A missing #N canvas header is supplied.

@param source the text problems are placed against, when it is not text itself

    fun openPatchText(text: String, name: String = "inline", source: String = text): PdPatch {
        val cleaned = normalisePatchText(text)
        val dir = Files.createTempDirectory("field-pd-").toFile().apply { deleteOnExit() }
        val safe = name.filter { it.isLetterOrDigit() || it == '_' || it == '-' }.ifEmpty { "inline" }
        val file = File(dir, "$safe.pd")
        file.writeText(cleaned)
        file.deleteOnExit()
        return openPatch(file, source)
    }

    private fun drainPrintsForOpen(): List<String> {
        // Prints produced while opening a patch are both problems (maybe) and console lines.
        val lines = ArrayList<String>()
        val keep = ArrayList<Any>()
        while (true) {
            val item = incoming.poll() ?: break
            if (item is PrintedLine) lines += item.text else keep += item
        }
        incoming.addAll(keep)
        return lines
    }

internal fun closePatch(patch: PdPatch)

    internal fun closePatch(patch: PdPatch) {
        if (closed) return
        current { PdNative.closeFile(patch.handle) }
        openPatches -= patch
    }

val patches: List<PdPatch> get() = openPatches.toList()

The patches open in this Pd.

messages to Pd

    private fun enqueue(op: (Arena) -> Unit) {
        check(!closed) { "this PdInstance is closed" }
        outgoing += op
    }

fun send(receiver: String, value: Number)

Send a number to [r receiver]. Queued; delivered at the next block or flush.

    fun send(receiver: String, value: Number) = enqueue { a ->
        PdNative.float(a.allocateFrom(receiver), value.toFloat())
    }

fun bang(receiver: String) = enqueue { a -> PdNative.bang(a.allocateFrom(receiver)) }

Send a bang to [r receiver].

fun symbol(receiver: String, symbol: String)

Send a symbol to [r receiver].

    fun symbol(receiver: String, symbol: String) = enqueue { a ->
        PdNative.symbol(a.allocateFrom(receiver), a.allocateFrom(symbol))
    }

fun list(receiver: String, vararg items: Any)

Send a list of numbers and symbols to [r receiver].

    fun list(receiver: String, vararg items: Any) = enqueue { a ->
        PdNative.startMessage(items.size.coerceAtLeast(1))
        addAtoms(a, items)
        PdNative.finishList(a.allocateFrom(receiver))
    }

fun message(receiver: String, selector: String, vararg args: Any)

Send selector args… to [r receiver] — a Pd message like set 3 or, to Pd itself, message("pd", "dsp", 0).

    fun message(receiver: String, selector: String, vararg args: Any) = enqueue { a ->
        PdNative.startMessage(args.size.coerceAtLeast(1))
        addAtoms(a, args)
        PdNative.finishMessage(a.allocateFrom(receiver), a.allocateFrom(selector))
    }

    private fun addAtoms(a: Arena, items: Array<out Any>) {
        for (item in items) when (item) {
            is Number -> PdNative.addFloat(item.toFloat())
            is Boolean -> PdNative.addFloat(if (item) 1f else 0f)
            else -> PdNative.addSymbol(a.allocateFrom(item.toString()))
        }
    }

fun flush()

Deliver queued messages now, then poll. For when nothing is calling render.

    fun flush() {
        current { drainOutgoing() }
        poll()
    }

    private fun drainOutgoing() {
        if (outgoing.isEmpty()) return
        Arena.ofConfined().use { a ->
            while (true) {
                val op = outgoing.poll() ?: break
                op(a)
            }
        }
    }

messages from Pd

fun receive(name: String, callback: (Message) -> Unit): Closeable {

Call callback with every message sent to [s name] in any patch of this Pd.

Delivered by poll, on the thread that polls — never on the audio thread. Closing the result stops this callback; the binding itself lasts until the last callback for the name goes.

    fun receive(name: String, callback: (Message) -> Unit): Closeable {
        val list = receivers.computeIfAbsent(name) { CopyOnWriteArrayList() }
        list += callback
        bindings.computeIfAbsent(name) { n ->
            current { Arena.ofConfined().use { a -> PdNative.bind(a.allocateFrom(n)) } }
        }
        return Closeable {
            list -= callback
            if (list.isEmpty()) {
                receivers.remove(name)
                bindings.remove(name)?.let { b -> if (!closed) current { PdNative.unbind(b) } }
            }
        }
    }

fun onPrint(callback: (String) -> Unit): Closeable {

Call callback with every whole line Pd prints — print, errors, warnings. Delivered by poll.

    fun onPrint(callback: (String) -> Unit): Closeable {
        printers += callback
        return Closeable { printers -= callback }
    }

fun lastValue(name: String): Message? = lastSeen[name]

The last message seen on each received name, safe to read from any thread.

val console: List<String> get() = synchronized(printLog) { printLog.toList() }

The most recent lines Pd printed, oldest first (at most 200).

fun poll(): Int

Hand every message Pd has sent since the last poll to its callbacks, on this thread.

@return how many messages and printed lines were delivered

    fun poll(): Int {
        var n = 0
        while (true) {
            val item = incoming.poll() ?: break
            n++
            when (item) {
                is PrintedLine -> {
                    synchronized(printLog) {
                        printLog.addLast(item.text)
                        while (printLog.size > 200) printLog.removeFirst()
                    }
                    for (p in printers) runCatching { p(item.text) }
                }
                is Message -> receivers[item.receiver]?.forEach { cb -> runCatching { cb(item) } }
                is Midi -> midiListeners.forEach { cb -> runCatching { cb(item) } }
            }
        }
        return n
    }

arrays

fun arraySize(name: String): Int

The length of Pd array name (a [table name] or an #X array), or -1 when there is none.

    fun arraySize(name: String): Int = current {
        drainOutgoing()
        Arena.ofConfined().use { a -> PdNative.arraySize(a.allocateFrom(name)) }
    }

fun readArray(name: String, offset: Int = 0, count: Int = -1): FloatArray

Read count values of array name from offset. Throws when the array is missing or too short.

    fun readArray(name: String, offset: Int = 0, count: Int = -1): FloatArray = current {
        drainOutgoing()
        Arena.ofConfined().use { a ->
            val nameC = a.allocateFrom(name)
            val size = PdNative.arraySize(nameC)
            if (size < 0) throw IllegalArgumentException("Pd has no array called '$name'")
            val n = if (count < 0) size - offset else count
            if (n <= 0) return@use FloatArray(0)
            val dest = a.allocate(JAVA_FLOAT, n.toLong())
            val status = PdNative.readArray(dest, nameC, offset, n)
            if (status != 0) throw IllegalArgumentException("cannot read $n values from '$name' at $offset (size $size)")
            dest.toArray(JAVA_FLOAT)
        }
    }

fun writeArray(name: String, values: FloatArray, offset: Int = 0)

Write values into array name from offset. Throws when the array is missing or too short.

    fun writeArray(name: String, values: FloatArray, offset: Int = 0) {
        current {
            drainOutgoing()
            Arena.ofConfined().use { a ->
                val nameC = a.allocateFrom(name)
                val src = a.allocateFrom(JAVA_FLOAT, *values)
                val status = PdNative.writeArray(nameC, offset, src, values.size)
                if (status != 0) {
                    throw IllegalArgumentException(
                        "cannot write ${values.size} values to '$name' at $offset (size ${PdNative.arraySize(nameC)})")
                }
            }
        }
    }

fun resizeArray(name: String, size: Int)

Resize array name to size values.

    fun resizeArray(name: String, size: Int) {
        current {
            Arena.ofConfined().use { a ->
                if (PdNative.resizeArray(a.allocateFrom(name), size.toLong()) != 0) {
                    throw IllegalArgumentException("Pd has no array called '$name'")
                }
            }
        }
    }

audio

fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, inL: FloatArray? = null, inR: FloatArray? = null)

Render numFrames of Pd's output into outL and outR. The owning thread's call.

Queued messages are delivered first. Pd works in whole blockSize blocks, so a request that is not a multiple of it leaves the rest of the last block for the next call: rendering 100 frames then 28 gives exactly what rendering 128 would. inL/inR feed [adc~] when there are inputs.

body — 44 lines, from line 496
    fun render(outL: FloatArray, outR: FloatArray, numFrames: Int, inL: FloatArray? = null, inR: FloatArray? = null) {
        var written = 0
        lock.withLock {
            if (closed) {
                Buf.clear(outL, 0, numFrames)
                Buf.clear(outR, 0, numFrames)
                return
            }
            PdNative.setInstance(handle)
            drainOutgoing()
            while (written < numFrames) {
                if (pendingCount == 0) {
                    renderBlock(inL, inR, written)
                }
                val take = minOf(pendingCount, numFrames - written)
                System.arraycopy(pendingL, pendingAt, outL, written, take)
                System.arraycopy(pendingR, pendingAt, outR, written, take)
                pendingAt += take
                pendingCount -= take
                written += take
            }
            framesRendered += numFrames
        }
    }

    private fun renderBlock(inL: FloatArray?, inR: FloatArray?, at: Int) {
        if (inputs > 0) {
            // Interleaved, as libpd wants it: frame-major, channel-minor.
            for (f in 0 until blockSize) {
                val l = inL?.getOrNull(at + f) ?: 0f
                val r = inR?.getOrNull(at + f) ?: l
                inBlock.setAtIndex(JAVA_FLOAT, (f * inputs).toLong(), l)
                if (inputs > 1) inBlock.setAtIndex(JAVA_FLOAT, (f * inputs + 1).toLong(), r)
            }
        }
        PdNative.processFloat(1, inBlock, outBlock)
        for (f in 0 until blockSize) {
            val l = outBlock.getAtIndex(JAVA_FLOAT, (f * outputs).toLong())
            pendingL[f] = l
            pendingR[f] = if (outputs > 1) outBlock.getAtIndex(JAVA_FLOAT, (f * outputs + 1).toLong()) else l
        }
        pendingAt = 0
        pendingCount = blockSize
    }

fun renderOffline(seconds: Double, chunk: Int = 512): Pair<FloatArray, FloatArray>

Render seconds of this Pd without an audio device, delivering Pd's messages to their callbacks as it goes. The whole path a test or a documentation figure needs.

@param chunk frames per render call; messages Pd sends are polled after each

    fun renderOffline(seconds: Double, chunk: Int = 512): Pair<FloatArray, FloatArray> {
        require(seconds > 0.0) { "seconds must be positive" }
        val frames = (seconds * sampleRate).toInt().coerceAtLeast(1)
        val left = FloatArray(frames)
        val right = FloatArray(frames)
        val bl = FloatArray(chunk)
        val br = FloatArray(chunk)
        var at = 0
        while (at < frames) {
            val n = minOf(chunk, frames - at)
            render(bl, br, n)
            System.arraycopy(bl, 0, left, at, n)
            System.arraycopy(br, 0, right, at, n)
            at += n
            poll()
        }
        return left to right
    }

val node: PdNode by lazy { PdNode(this) }

This Pd as a node in Field's audio graph. See PdNode.

override fun close()

Close every patch, free the Pd and its hooks. Safe to call twice.

    override fun close() {
        lock.withLock {
            if (closed) return
            PdNative.setInstance(handle)
            for (b in bindings.values) runCatching { PdNative.unbind(b) }
            bindings.clear()
            for (p in openPatches) runCatching { PdNative.closeFile(p.handle) }
            openPatches.clear()
            PdNative.freeInstance(handle)
            closed = true
        }
        // The upcall stubs die with the arena, which is why this is after freeInstance.
        runCatching { arena.close() }
    }

val isClosed: Boolean get() = closed

override fun toString(): String

    override fun toString(): String =
        "PdInstance(${sampleRate}Hz, $inputs in / $outputs out, ${openPatches.size} patch(es)${if (closed) ", closed" else ""})"

companion object

    companion object {

fun normalisePatchText(text: String): String

The text of a patch, ready for Pd: common indentation removed, blank lines dropped, a #N canvas header supplied when the text starts straight in with objects.

        fun normalisePatchText(text: String): String {
            val lines = text.trimIndent().lines().map { it.trimEnd() }.filter { it.isNotBlank() }
            val body = lines.joinToString("\n")
            return if (lines.firstOrNull()?.startsWith("#N canvas") == true) "$body\n"
            else "#N canvas 0 0 450 300 12;\n$body\n"
        }
    }
}

class PdPatchError(message: String, val problems: List<PdPatch.Problem>) : RuntimeException(message)

Pd could not open a patch at all. problems carries what it said.

class PdPatchError(message: String, val problems: List<PdPatch.Problem>) : RuntimeException(message)

class PdPatch internal constructor

One open patch in a PdInstance.

@property dollarZero the patch's $0, which makes a name like $0-freq local to this copy

class PdPatch internal constructor(
    val instance: PdInstance,
    internal val handle: MemorySegment,
    val dollarZero: Int,
    val file: File,
    val source: String,
) : Closeable {

data class Problem(val message: String, val line: Int)

Something Pd said while opening the patch, placed on the line of source it is about when that can be told. line is 0-based within source; -1 when it cannot be placed.

var problems: List<Problem>

What Pd complained about while opening this patch — objects it could not create, mostly.

    var problems: List<Problem> = emptyList()
        internal set

override fun close() = instance.closePatch(this)

Close this patch; the rest of its instance keeps running.

override fun toString(): String

    override fun toString(): String = "PdPatch(${file.name}, \$0=$dollarZero, ${problems.size} problem(s))"

internal companion object

    internal companion object {

fun problemsIn(source: String, printed: List<String>, known: Set<String> = PdObjects.catalog()): List<Problem>

Place Pd's open-time console output on the patch's lines.

Pd reports a failed object as two lines: the object's text, then ... couldn't create. The object text is found on the #X obj line that carries it, and the problem says which object of known (PdObjects.catalog) was probably meant.

        fun problemsIn(source: String, printed: List<String>, known: Set<String> = PdObjects.catalog()): List<Problem> {
            val lines = source.lines()
            val out = ArrayList<Problem>()
            // Pd 0.56 prefixes the lines of an error with "error: " and sometimes "verbose(0): ";
            // older ones did not.
            val prefix = Regex("""^(?:(?:error:|verbose\(\d+\):)\s*)+""")
            val bare = printed.map { it.trim().replace(prefix, "").trim() }
            for ((k, line) in bare.withIndex()) {
                if (line.startsWith("... couldn't create") && k > 0) {
                    val what = bare[k - 1]
                    out.removeAll { it.line < 0 && it.message.endsWith(what) }
                    val at = lines.indexOfFirst { l ->
                        val t = l.trim()
                        t.startsWith("#X obj") && t.removeSuffix(";").split(Regex("\\s+")).drop(4).joinToString(" ") == what
                    }
                    val hint = PdObjects.hint(what, known)
                    out += Problem("Pd couldn't create [$what]" + if (hint.isEmpty()) "" else " — $hint", at)
                } else if (printed[k].trim().startsWith("error:") || line.startsWith("warning:") ||
                    line.contains("can't connect")) {
                    out += Problem(line, -1)
                }
            }
            return out
        }
    }
}

class PdNode(val pd: PdInstance, private val input: afield.audio.synth.StereoRenderable? = null) : StereoNode()

A PdInstance in Field's audio graph: audio(pd.node) plays it.

A StereoNode because a Pd is stateful — rendering it twice in one block would advance every oscillator twice — and the node's per-block cache is what prevents that when a graph reuses it. Whatever thread renders the graph (the audio thread, live) becomes the thread that runs Pd.

class PdNode(val pd: PdInstance, private val input: afield.audio.synth.StereoRenderable? = null) : StereoNode() {
    private var inL = FloatArray(0)
    private var inR = FloatArray(0)

override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext)

    override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (input != null && pd.inputs > 0) {
            if (inL.size < numFrames) {
                inL = FloatArray(numFrames)
                inR = FloatArray(numFrames)
            }
            Buf.clear(inL, 0, numFrames)
            Buf.clear(inR, 0, numFrames)
            input.render(inL, inR, numFrames, ctx)
            pd.render(outL, outR, numFrames, inL, inR)
        } else {
            pd.render(outL, outR, numFrames)
        }
    }

override fun toString(): String = "PdNode($pd)"