Skip to content

PatSpatial.kt

src/jvmMain/kotlin/afield/audio/synth/PatSpatial.kt · 1329 lines

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

import afield.document.graph.Box
import afield.runtime.lifecycle.RunsAt
import afield.runtime.execution.ReturnValueRegistry
import afield.audio.spatial.steamaudio.AudioBuffer
import afield.audio.spatial.steamaudio.AudioSettings
import afield.audio.spatial.steamaudio.BinauralEffect
import afield.audio.spatial.steamaudio.BinauralEffectParams
import afield.audio.spatial.steamaudio.DirectEffect
import afield.audio.spatial.steamaudio.DirectEffectFlags
import afield.audio.spatial.steamaudio.DirectEffectParams
import afield.audio.spatial.steamaudio.Hrtf
import afield.audio.spatial.steamaudio.HrtfInterpolation
import afield.audio.spatial.steamaudio.SteamAudio
import afield.audio.spatial.steamaudio.SteamAudioContext
import afield.runtime.lifecycle.inMain
import afield.document.graph.registerLaunchJob
import afield.math.linalg.Quat
import afield.math.linalg.Vec3
import afield.runtime.lifecycle.returnValueRegistration
import kotlinx.coroutines.Job
import kotlinx.coroutines.yield
import java.lang.foreign.Arena
import java.nio.file.Path
import java.nio.file.Paths
import java.util.PriorityQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.sqrt

A pattern placed in space: pat(...) -> DoughEngine -> Steam Audio HRTF, in realtime.

Three subsystems that do not know about each other, joined by two coercions and one thread:

what it is what this file asks of it
PatPattern Tidal-style pattern language, polyphonic by construction query a cycle, read the annotations
DoughEngine the Strudel/superdough wasm synth, 32 voices one engine per spatial source
afield.audio.spatial.steamaudio Steam Audio 4.8.1 over FFM direct path plus HRTF, per source, per block

The tag is the spatial parameter

A pattern's named parameters ride on PatEvent.annotations, and with writes any key at all into that map, so the pattern language needed no extension to describe where a sound is. Six keys are read here and every other key is forwarded to Dough untouched:

tag means default
spat which spatial source -- i.e. which Dough engine -- the event plays on "0"
az azimuth in degrees; 0 ahead, +90 to the right 0
elev elevation in degrees; +90 overhead 0
dist distance in metres 1.5
occl occlusion, 0 clear to 1 fully blocked 0
air 1 to apply air absorption over distance, 0 for none 1
patSpatial(
    pat("bd ~ sd ~, hh*8").with(
        paramOf("spat", pat("<kit hats>")),
        param("az", sine().range(-70.0, 70.0)),
        param("dist", "1 3 1 6"),
    ),
    cps = 2.0,
)

Because az can take a continuous pattern, sine() sampled per event is a source that moves between notes; and because the placement is held on the source rather than the note, the same pattern's next event on that spat group moves the whole group. The tags do not decorate the sound, they aim it.

Why one Dough engine per source

Steam Audio spatialises a mono signal -- a direction is a property of a whole buffer. DoughEngine mixes its 32 voices to stereo and MAX_ORBITS is 1, so there is no way to pull two separable signals out of one engine. Hence one engine per spat group, which is also why spat exists at all rather than every event getting its own position.

Two consequences:

  • A pattern's pan is overridden. Every event is sent with pan/0, which puts all of that engine's energy in its left channel, and that channel is the mono source signal. Panning is lossless this way (no 0.5*(L+R) summing) but pan as a musical parameter is gone: az replaces it, and it is a better parameter.
  • Note onsets quantise to a Dough block, 128 frames or 2.9 ms at 44.1 kHz. Dough takes an event through evaluate() and applies it at the next dsp(), so unlike PatSynthBank -- which is sample-exact, and proves it by rendering bit-identically at four buffer sizes -- this path cannot place a note inside a block. That is a property of Dough's event API, not of the scheduler.

Why Dough is not on the audio thread

PatSynth puts the pattern query on a feeder thread because querying allocates, and keeps synthesis on the audio thread because Field's own voices are a fixed amount of float arithmetic. Neither half of that argument survives here. DoughEngine is GraalWasm: every block enters a polyglot context, and the JIT and the collector get a say in how long that takes.

PatSpatialBudgetProbe measures it, idle and under load; the percentiles are in dev_docs/PAT_SPATIAL_MEASUREMENTS.md. The median is flat -- the arithmetic of four wasm engines and eight Steam Audio effects is 4% of a block either way, so nothing here needs optimising. The tail moves by a factor of three with load the audio thread has no say over. A path whose median fits twenty-five times over and which still misses one block in a hundred is not a path to shave down; it is one to decouple.

So PatSpatialRoom renders on its own thread into PatSpatialRing, and the audio thread's entire job is a bounded copy out of that ring. A ring holding more milliseconds than the worst stall turns a dropout into a dip in PatSpatialRing.fill, and PatSpatialRing.underruns is the number that says whether that held.

The one stall the ring cannot absorb is booting a DoughEngine, at 130-500 ms. That is what PatSpatialRoom.prescanCycles exists for, and what PatSpatialRoom.lateSourceBoots counts when a spat tag turns up too late to have been prescanned.

What limits the render thread

Not the arithmetic. Under heavy load PatSpatialEngineSmoke has recorded the render thread missing most of a window's blocks while busy only a third of the elapsed time; that run is in dev_docs/PAT_SPATIAL_MEASUREMENTS.md. A thread that is idle two thirds of the time and still misses its deadline is not a thread that needs faster code; it is a thread that is not being scheduled.

Two things follow, and both are in the code rather than in advice:

  • PatSpatialRoom.busyMsTotal against elapsed time separates the two ways of falling behind. Near 100% busy with underruns means the work does not fit and this design is wrong. Far below it means the machine is oversubscribed, and PatSpatialEngineSmoke says so instead of failing.
  • The ring is sized for the scheduler's stalls, not for this code's, which is why it defaults to 341 ms rather than the ~40 ms the arithmetic alone would justify.

Raising the render thread's priority was tried and reverted; the comment in PatSpatialRoom.start records what it did instead of helping.

Where this is written up for a reader

public/docs/pat_spatial.md — the page that decides whether to use this, rather than how it works. It carries the findings without the percentiles, plus the two things this file cannot hold: PatSpatialSoundDocs' published clips of the mono source, and the argument for why the spatialised output has no clip and cannot have one. It is also declared in SoundPathMap as the pat-spatial path, which is what keeps that page and this file from drifting apart.

data class PatSpatialPlacement

Where a spatial source is, in Steam Audio's listener-relative frame. Metres and degrees.

data class PatSpatialPlacement(
    /** Azimuth in degrees: 0 ahead, +90 to the listener's right. */
    val azimuthDeg: Double = 0.0,
    /** Elevation in degrees: 0 at ear height, +90 overhead. */
    val elevationDeg: Double = 0.0,
    /** Distance in metres. Clamped to [MIN_DISTANCE] so a source cannot land inside the head. */
    val distance: Double = 1.5,
    /** Occlusion, 0 clear to 1 fully blocked. */
    val occlusion: Double = 0.0,
    /** Whether the direct path loses high frequencies over distance. */
    val airAbsorption: Boolean = true,
) {

fun direction(): FloatArray

Unit direction in Steam Audio's convention: -Z ahead, +X right, +Y up.

    fun direction(): FloatArray {
        val az = Math.toRadians(azimuthDeg)
        val el = Math.toRadians(elevationDeg)
        val ce = cos(el)
        return floatArrayOf(
            (sin(az) * ce).toFloat(),
            sin(el).toFloat(),
            (-cos(az) * ce).toFloat(),
        )
    }

companion object

    companion object {

const val MIN_DISTANCE = 0.4

Steam Audio's inverse-distance curve stops steepening here, so nothing blows up at zero.

data class HeadPose

Where the listener's head is in the room, as position plus orientation quaternion.

The frame is OpenXR's and Steam Audio's, which are the same one: +X right, +Y up, -Z ahead, right-handed, metres. That identity is why ofOpenXR is a copy and not a conversion, and HeadTrackedSpatialSmokeTest renders real audio to hold it -- a handedness error here would invert left and right and change nothing else, which is the one audio bug that sounds fine.

The room's origin is wherever the pose source calls the origin. For OpenXR that is the runtime's LOCAL space, so a spat source at dist 2 sits two metres from where the headset was when the runtime last recentred, not two metres from the wearer.

data class HeadPose(
    val x: Double = 0.0,
    val y: Double = 0.0,
    val z: Double = 0.0,
    val qx: Double = 0.0,
    val qy: Double = 0.0,
    val qz: Double = 0.0,
    val qw: Double = 1.0,
) {

val azimuthDeg: Double

This pose's heading in degrees, positive to the right -- the quantity PatSpatialRoom.turnListener takes. Yaw alone does not describe a head that is also nodding or tilted; this is a readout, and the spatialisation uses the whole quaternion.

    val azimuthDeg: Double
        get() {
            // The heading of the head's own forward axis, q * (0, 0, -1). A turn to the right is a
            // *negative* rotation about +Y -- which is where both minus signs come from.
            val fx = -2.0 * (qx * qz + qw * qy)
            val fz = -(1.0 - 2.0 * (qx * qx + qy * qy))
            return Math.toDegrees(atan2(fx, -fz))
        }

companion object

val IDENTITY = HeadPose()

fun turnedRight(azimuthDeg: Double): HeadPose

A head turned azimuthDeg to the right at the room's origin, and level.

        fun turnedRight(azimuthDeg: Double): HeadPose {
            val half = Math.toRadians(-azimuthDeg) * 0.5
            return HeadPose(qy = sin(half), qw = cos(half))
        }

fun ofOpenXR(position: Vec3, orientation: Quat): HeadPose

An OpenXR (or afield.graphics.vulkan.targets.StereoPoseSource) head pose, copied without conversion.

        fun ofOpenXR(position: Vec3, orientation: Quat): HeadPose = HeadPose(
            x = position.x.toDouble(), y = position.y.toDouble(), z = position.z.toDouble(),
            qx = orientation.x.toDouble(), qy = orientation.y.toDouble(),
            qz = orientation.z.toDouble(), qw = orientation.w.toDouble(),
        )
    }
}

data class PatSpatialEvent

One pattern event, resolved into a Dough event and a placement, stamped with its start frame.

Everything the render thread needs is decided before it arrives: which engine, what to say to it, and where the engine now is. No map lookups happen per block.

data class PatSpatialEvent(
    /** Absolute frame on [PatSpatialRoom.framesRendered]'s clock. */
    val startFrame: Long,
    /** The `spat` tag: which source this plays on. */
    val sourceKey: String,
    /** The Dough event, already carrying `pan/0`. */
    val doughEvent: Map<String, Any?>,
    /** Where the source moves to when this event starts. */
    val placement: PatSpatialPlacement,
)

Keys this file consumes itself rather than forwarding to Dough.

private val SPATIAL_TAGS = setOf("spat", "az", "elev", "dist", "occl", "air")

fun patSpatialEventsForArc

The events of pattern over arc, as Dough events with placements.

Runs on the render thread, ahead of the audio. The s default is a sawtooth because an untagged pattern should make an audible noise rather than nothing; give events an s to choose.

body — 64 lines, from line 261
fun patSpatialEventsForArc(
    pattern: PatPattern<String>,
    arc: PatArc,
    cps: Double,
    sampleRate: Int,
    gain: Double = 1.0,
    originFrame: Long = 0L,
    defaultSound: String = "saw",
): List<PatSpatialEvent> {
    require(cps > 0.0) { "cps must be > 0, got $cps" }
    val secondsPerCycle = 1.0 / cps
    return pattern.events(arc)
        .sortedWith(compareBy({ it.arc.start }, { it.arc.end }))
        .map { event ->
            val startSec = event.arc.start.toDouble() * secondsPerCycle
            val arcSec = (event.arc.end - event.arc.start).toDouble() * secondsPerCycle
            val ann = event.annotations

            val dough = linkedMapOf<String, Any?>("dough" to "play")
            // The atom itself names a sound or a note, the way the rest of the pattern language
            // reads it: a number is a MIDI note, a word is a sound.
            val atom = event.value.trim()
            val atomNote = atom.toDoubleOrNull()
            if (atomNote != null) {
                dough["s"] = ann["s"] ?: ann["sound"] ?: defaultSound
                dough["note"] = patFormatNumber(atomNote)
            } else {
                dough["s"] = if (atom.isEmpty() || atom == "~") (ann["s"] ?: defaultSound) else atom
            }
            // Explicit annotations win over anything inferred from the atom.
            ann["note"]?.let { dough["note"] = it }
            ann["n"]?.let { dough["note"] = it }
            if (dough["note"] == null) dough["note"] = "60"

            // Dough's own amplitude parameter. The event's `gain` is folded in here so that a
            // pattern written for patSynth sounds the same shape through this path.
            val vel = (ann["velocity"]?.toDoubleOrNull() ?: ann["gain"]?.toDoubleOrNull() ?: 0.8) * gain
            dough["velocity"] = patFormatNumber(vel.coerceIn(0.0, 4.0))
            // The note's length in seconds, so a pattern's rhythm reaches the synth's envelope.
            dough["duration"] = patFormatNumber(arcSec * (ann["sustain"]?.toDoubleOrNull() ?: 1.0))

            // Everything not claimed as a spatial tag, and not already set, goes to Dough verbatim.
            // That is what makes `lpf`, `room`, `crush`, `fmi` and the rest available for free.
            for ((k, v) in ann) {
                if (k in SPATIAL_TAGS || k in dough || k == "gain" || k == "sustain") continue
                dough[k] = v
            }
            // Last, and not negotiable: the engine must hand us a mono signal.
            dough["pan"] = "0"

            PatSpatialEvent(
                startFrame = originFrame + Math.round(startSec * sampleRate),
                sourceKey = ann["spat"]?.trim()?.ifEmpty { null } ?: "0",
                doughEvent = dough,
                placement = PatSpatialPlacement(
                    azimuthDeg = ann["az"]?.toDoubleOrNull() ?: 0.0,
                    elevationDeg = (ann["elev"]?.toDoubleOrNull() ?: 0.0).coerceIn(-90.0, 90.0),
                    distance = max(ann["dist"]?.toDoubleOrNull() ?: 1.5, PatSpatialPlacement.MIN_DISTANCE),
                    occlusion = (ann["occl"]?.toDoubleOrNull() ?: 0.0).coerceIn(0.0, 1.0),
                    airAbsorption = (ann["air"]?.toDoubleOrNull() ?: 1.0) > 0.5,
                ),
            )
        }
}

class PatSpatialRing(requestedFrames: Int)

A single-producer single-consumer ring of interleaved stereo frames.

The whole point of this file's threading, so it is worth being precise about: the render thread writes, the audio thread reads, and neither ever blocks or allocates. Both indices are monotonic AtomicLongs and the capacity is a power of two, so a position becomes an offset with a mask and there is no wraparound arithmetic to get wrong.

An underruns count is not a diagnostic afterthought -- it is the one number that says whether this design is working. Zero means the render thread stayed ahead.

class PatSpatialRing(requestedFrames: Int) {

val capacityFrames: Int = Integer.highestOneBit(requestedFrames.coerceAtLeast(1024) * 2 - 1)

Frames of headroom. Rounded up to a power of two.

    private val buffer = FloatArray(capacityFrames * 2)
    private val mask = (capacityFrames - 1).toLong()
    private val writePos = AtomicLong(0L)
    private val readPos = AtomicLong(0L)

val fill: Int get() = (writePos.get() - readPos.get()).toInt()

Frames written but not yet consumed.

val room: Int get() = capacityFrames - fill

Frames the writer may add right now.

var underruns: Long

Frames the audio thread asked for and the ring could not supply.

    @Volatile
    var underruns: Long = 0L
        private set

var minFill: Int

Lowest fill the audio thread has ever seen, in frames. How close this came to a dropout.

    @Volatile
    var minFill: Int = Int.MAX_VALUE
        private set

internal fun resetStatistics()

Forget the underrun and fill history.

Called once, by the render thread, at the moment the ring first reaches its target fill. Up to that point nothing is consuming and the render thread has no deadline, so warmup figures describe a period in which an underrun was neither possible nor meaningful.

    internal fun resetStatistics() {
        underruns = 0L
        minFill = Int.MAX_VALUE
    }

fun write(l: FloatArray, r: FloatArray, n: Int)

Write n frames from l and r. Called only by the render thread.

    fun write(l: FloatArray, r: FloatArray, n: Int) {
        val w = writePos.get()
        for (i in 0 until n) {
            val slot = (((w + i) and mask) * 2L).toInt()
            buffer[slot] = l[i]
            buffer[slot + 1] = r[i]
        }
        writePos.set(w + n)
    }

fun readInto(outL: FloatArray, outR: FloatArray, n: Int)

Add n frames into outL and outR. Called only by the audio thread.

Additive, because that is what a StereoNode contract is, and short reads leave the tail of the block untouched rather than writing silence over whatever else is in the mix.

    fun readInto(outL: FloatArray, outR: FloatArray, n: Int) {
        val available = fill
        if (available < minFill) minFill = available
        val take = if (available < n) {
            underruns += (n - available).toLong()
            available
        } else {
            n
        }
        val rd = readPos.get()
        for (i in 0 until take) {
            val slot = (((rd + i) and mask) * 2L).toInt()
            outL[i] += buffer[slot]
            outR[i] += buffer[slot + 1]
        }
        readPos.set(rd + take)
    }
}

class PatSpatialSource internal constructor

One spatial source: a Dough engine, and the Steam Audio effects that place its output.

Owned entirely by the render thread -- created there and closed there -- because both a DoughEngine's polyglot context and Steam Audio's effect state are cheapest when exactly one thread ever touches them.

target is where the pattern last said to be and current is where the render has got to. Interpolating between them is not cosmetic: a note that jumps 140 degrees of azimuth between one block and the next steps the HRTF filter discontinuously, and that is audible as a click on every note. One pole per block, so a placement change lands over a few milliseconds.

class PatSpatialSource internal constructor(
    val key: String,
    internal val engine: DoughEngine,
    internal val direct: DirectEffect,
    internal val binaural: BinauralEffect,
    internal val directParams: DirectEffectParams,
    internal val binauralParams: BinauralEffectParams,
    internal val dry: AudioBuffer,
    internal val shaped: AudioBuffer,
    internal val stereo: AudioBuffer,
) : AutoCloseable {

internal var target = PatSpatialPlacement()

internal var current = PatSpatialPlacement()

internal val mono = FloatArray(DoughEngine.BLOCK_SIZE)

internal val blockL = FloatArray(DoughEngine.BLOCK_SIZE)

internal val blockR = FloatArray(DoughEngine.BLOCK_SIZE)

internal val airGains = FloatArray(SteamAudio.NUM_BANDS)

var notesPlayed: Long

Notes started on this source.

    @Volatile
    var notesPlayed: Long = 0L
        internal set

val placement: PatSpatialPlacement get() = current

Where this source is right now, as the render thread last placed it.

var activeVoices: Int

Voices sounding in this source's engine, as the render thread last published them.

Not a live read, and it cannot be one: a GraalVM polyglot context may only be entered by the thread that built it, so DoughEngine.activeVoices() throws "Multi threaded access requested ... but is not allowed for language(s) js" from anywhere but the render thread. That is also the deeper reason the engines are created inside PatSpatialRoom.renderLoop -- a Dough engine is not merely best owned by one thread, it is owned by one thread permanently.

    @Volatile
    var activeVoices: Int = 0
        internal set

var peakVoices: Int

The most voices ever seen sounding at once on this source. A sampled chord shows up here.

    @Volatile
    var peakVoices: Int = 0
        internal set

override fun close()

    override fun close() {
        binaural.close()
        direct.close()
        engine.close()
    }

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this, "key" to key, "az" to current.azimuthDeg, "dist" to current.distance,
        "notes" to notesPlayed,
    )
}

class PatSpatialRoom internal constructor

The render thread: pattern in, spatialised stereo into a PatSpatialRing.

It does three jobs in one loop, in this order, and the order is the design:

  1. Query ahead. Ask pattern for whole cycles until it has covered the ring's horizon.
  2. Start what is due. Hand each event to its source's engine and move that source's target.
  3. Render one block per source, spatialise it, sum, and push.

Sources are created lazily, on the first event that names them, because booting a DoughEngine costs a few hundred milliseconds and 200 MB of wasm memory -- a spat tag nobody uses should not pay for that. They are created here on the render thread, never on the caller's.

class PatSpatialRoom internal constructor(
    val pattern: PatPattern<String>,
    val cps: Double,
    val sampleRate: Int,
    val gain: Double,
    val ring: PatSpatialRing,
    val wasmPath: Path,
    val maxSources: Int,
    /** Frames the render thread tries to keep in the ring. Its tolerance for its own stalls. */
    val targetFillFrames: Int,
    val hrtfInterpolation: HrtfInterpolation,
    val defaultSound: String,
    /** Cycles scanned up front for `spat` tags, so their engines boot before any audio is due. */
    val prescanCycles: Int,
    // A pose per render frame, or null to follow setListenerPose. See setListenerPoseAt.
    listenerPoseAt: ((frame: Long) -> HeadPose)? = null,
) {

    @Volatile
    private var listenerPoseAt: ((frame: Long) -> HeadPose)? = listenerPoseAt

    private val running = AtomicBoolean(false)
    private var thread: Thread? = null
    private val frames = AtomicLong(0L)
    private val sourcesByKey = LinkedHashMap<String, PatSpatialSource>()
    private val pending = PriorityQueue<PatSpatialEvent>(64, compareBy { it.startFrame })

The newest pose a caller has offered, latest-wins.

A queue would be wrong: a head-tracked listener produces poses at the tracker's rate and the render thread consumes them at the block rate, and when those disagree the block wants the newest pose, not the oldest unread one. Only the reference is published, so the render thread reads a whole pose or the previous whole pose, never half of each.

    private val listenerRequest = AtomicReference(HeadPose.IDENTITY)

val framesRendered: Long get() = frames.get()

Frames the render thread has produced. The clock a PatSpatialEvent is stamped on.

var cyclesQueried: Long

Cycles of pattern queried so far.

    @Volatile
    var cyclesQueried: Long = 0L
        private set

var ready: Boolean

True once Steam Audio is up, every prescanned engine has booted, and the ring has filled.

Not "the thread started". This is the point at which attaching a realtime consumer is safe, which is why PatSpatialLaunch.awaitReady waits for it.

    @Volatile
    var ready: Boolean = false
        private set

var failure: Throwable?

Non-null if the render thread died; the reason it died.

    @Volatile
    var failure: Throwable? = null
        private set

var lateSourceBoots: Int

Engines booted after the ring was already feeding an audio thread.

Each one is a likely dropout, and this is the honest way to say so. Booting a DoughEngine takes 100-400 ms inside the render loop, which is longer than the ring holds, so a spat tag that first appears in cycle 40 stalls the render thread past the audio thread's supply. prescanCycles is what keeps this at zero for the ordinary case -- a pattern whose tags are all visible in its first few cycles has every engine booted before a sample is produced.

    @Volatile
    var lateSourceBoots: Int = 0
        private set

var worstBlockMs: Double

Longest single block the render thread has taken, in milliseconds.

    @Volatile
    var worstBlockMs: Double = 0.0
        private set

var blocksRendered: Long

Blocks rendered since the ring first filled.

    @Volatile
    var blocksRendered: Long = 0L
        private set

var slowBlocks: Long

Blocks that took longer than 5 ms -- longer than the audio they produced.

    @Volatile
    var slowBlocks: Long = 0L
        private set

var stallBlocks: Long

Blocks that took longer than 50 ms, which no reasonable ring depth absorbs repeatedly.

    @Volatile
    var stallBlocks: Long = 0L
        private set

var slowBlockMsTotal: Double

Total milliseconds spent in blocks over 5 ms. How much lateness there is to absorb.

    @Volatile
    var slowBlockMsTotal: Double = 0.0
        private set

val busyMsTotal: Double get() = queryMsTotal + eventMsTotal + synthMsTotal + spatialiseMsTotal

Milliseconds this thread spent doing its own work since the ring filled.

Compare it with elapsed wall time to tell the two ways of falling behind apart. Near 100% means the work genuinely does not fit and the design is wrong. Far below it, with underruns, means the thread was not scheduled -- the machine is oversubscribed and no amount of making this loop faster will help.

var queryMsTotal: Double

Where the render thread spends its time, split by stage, in milliseconds.

    @Volatile
    var queryMsTotal: Double = 0.0
        private set

var eventMsTotal: Double

    @Volatile
    var eventMsTotal: Double = 0.0
        private set

var synthMsTotal: Double

    @Volatile
    var synthMsTotal: Double = 0.0
        private set

var spatialiseMsTotal: Double

    @Volatile
    var spatialiseMsTotal: Double = 0.0
        private set

val sources: List<PatSpatialSource> get()

Sources that exist, in the order their spat tags were first seen.

    val sources: List<PatSpatialSource> get() = synchronized(sourcesByKey) { sourcesByKey.values.toList() }

fun turnListener(azimuthDeg: Double)

Turn the listener's head to azimuthDeg, from any thread.

Positive is a turn to the right, and a source dead ahead moves to the listener's left as the head turns right. The render thread applies it at the top of a block.

    fun turnListener(azimuthDeg: Double) {
        listenerRequest.updateAndGet { previous ->
            // Turning a head does not move it. Preserving the position matters once a pose source
            // is driving this room: a turn must not teleport the listener back to the origin.
            HeadPose.turnedRight(azimuthDeg).copy(x = previous.x, y = previous.y, z = previous.z)
        }
    }

fun setListenerPose(pose: HeadPose)

Move and turn the listener's head, from any thread. The render thread applies the newest pose it finds at the top of a block; poses offered faster than that are skipped, not queued.

Unlike turnListener this carries a position, so the head's own translation reaches the spatialisation: leaning toward a source makes it nearer and walking past one sweeps it across. HeadTrackedListener is what normally calls this, from an OpenXR headset.

    fun setListenerPose(pose: HeadPose) {
        listenerRequest.set(pose)
    }

fun setListenerPoseAt(poseAt: ((frame: Long) -> HeadPose)?) {

Give the listener a pose that is a function of the render frame, or null to go back to setListenerPose.

The render thread calls poseAt at the top of every block with that block's first frame on framesRendered's clock, so the head and the audio share one timeline and a render repeats exactly. A pose offered with setListenerPose instead lands on whichever block the render thread reaches next, which differs from run to run by a block or two. Use this for a scripted head in an offline render; a real headset reports poses as they happen and goes through HeadTrackedListener. poseAt runs on the render thread, so it must be cheap and must not block.

    fun setListenerPoseAt(poseAt: ((frame: Long) -> HeadPose)?) {
        listenerPoseAt = poseAt
    }

val listenerAzimuthDeg: Double get() = listenerPose.azimuthDeg

The listener's heading in degrees, as the render thread last applied it.

var listenerPose: HeadPose

The whole pose the render thread last applied, position included.

    @Volatile
    var listenerPose: HeadPose = HeadPose.IDENTITY
        private set

World -> head rotation for listenerPose, row-major, as nine floats.

The transpose of the head's own rotation, recomputed only when the pose changes and read by every source in the block. Kept as floats rather than a Quat because the alternative -- Quat.conjugate().transform(...) per source per block -- both allocates and mutates the quaternion it is called on.

    private val worldToHead = FloatArray(9).also { it[0] = 1f; it[4] = 1f; it[8] = 1f }

    private fun applyListenerPose(pose: HeadPose) {
        listenerPose = pose
        val x = pose.qx; val y = pose.qy; val z = pose.qz; val w = pose.qw
        val n = sqrt(x * x + y * y + z * z + w * w)
        if (n < 1e-9) {
            // A zero quaternion is a pose source that has not filled anything in. Level and facing
            // -Z is the one answer that cannot be mistaken for tracking.
            worldToHead.fill(0f)
            worldToHead[0] = 1f; worldToHead[4] = 1f; worldToHead[8] = 1f
            return
        }
        val qx = x / n; val qy = y / n; val qz = z / n; val qw = w / n
        // Rows of head->world, which written across are the columns of world->head: the transpose
        // is the inverse for a rotation, and this is where it is taken.
        worldToHead[0] = (1 - 2 * (qy * qy + qz * qz)).toFloat()
        worldToHead[3] = (2 * (qx * qy - qw * qz)).toFloat()
        worldToHead[6] = (2 * (qx * qz + qw * qy)).toFloat()
        worldToHead[1] = (2 * (qx * qy + qw * qz)).toFloat()
        worldToHead[4] = (1 - 2 * (qx * qx + qz * qz)).toFloat()
        worldToHead[7] = (2 * (qy * qz - qw * qx)).toFloat()
        worldToHead[2] = (2 * (qx * qz - qw * qy)).toFloat()
        worldToHead[5] = (2 * (qy * qz + qw * qx)).toFloat()
        worldToHead[8] = (1 - 2 * (qx * qx + qy * qy)).toFloat()
    }

internal fun start()

body — 221 lines, from line 705
    internal fun start() {
        if (!running.compareAndSet(false, true)) return
        thread = kotlin.concurrent.thread(isDaemon = true, name = "pat-spatial-room") {
            // Deliberately left at the default priority. Raising it to MAX_PRIORITY was tried and
            // reverted: once the ring is full this thread polls on a 2 ms sleep, and as a
            // high-priority poller on a loaded machine it preempted the audio engine's own 4 ms
            // loop badly enough that OpenAL stopped reporting processed buffers at all -- the
            // output went silent while this thread sat on a full ring, which looks nothing like a
            // priority problem and cost an hour to pin on the one line that caused it.
            try {
                renderLoop()
            } catch (t: Throwable) {
                failure = t
            } finally {
                ready = false
                synchronized(sourcesByKey) {
                    sourcesByKey.values.forEach { runCatching { it.close() } }
                    sourcesByKey.clear()
                }
            }
        }
    }

    private fun renderLoop() {
        val settings = AudioSettings(samplingRate = sampleRate, frameSize = DoughEngine.BLOCK_SIZE)
        SteamAudioContext().use { context ->
            Hrtf.default(context, settings, volume = 1.0f).use { hrtf ->
                Arena.ofShared().use { arena ->
                    val mixL = FloatArray(DoughEngine.BLOCK_SIZE)
                    val mixR = FloatArray(DoughEngine.BLOCK_SIZE)
                    val lane = FloatArray(DoughEngine.BLOCK_SIZE * 2)
                    var cycle = 0L

                    // Boot every engine the pattern's first cycles ask for, before producing a
                    // sample. The alternative -- booting on the first event that names a tag --
                    // puts a few hundred milliseconds of wasm startup inside a block that owes the
                    // audio thread 2.9 ms of audio, and the ring is not deep enough to hide it.
                    for (key in prescanSourceKeys()) {
                        if (!running.get()) return
                        sourceFor(context, settings, hrtf, arena, key, late = false)
                    }

                    while (running.get()) {
                        val offered = listenerPoseAt?.invoke(frames.get()) ?: listenerRequest.get()
                        if (offered !== listenerPose) applyListenerPose(offered)

                        // 1. Query ahead far enough to cover everything the ring will hold.
                        val framesPerCycle = sampleRate / cps
                        val horizon = frames.get() + ring.capacityFrames + framesPerCycle
                        var queried = 0
                        val queryStart = System.nanoTime()
                        while (running.get() && cycle * framesPerCycle <= horizon && queried < 64) {
                            pending.addAll(
                                patSpatialEventsForArc(
                                    pattern = pattern,
                                    arc = PatArc(PatQ.of(cycle), PatQ.of(cycle + 1)),
                                    cps = cps,
                                    sampleRate = sampleRate,
                                    gain = gain,
                                    originFrame = 0L,
                                    defaultSound = defaultSound,
                                ),
                            )
                            cycle++
                            cyclesQueried = cycle
                            queried++
                        }
                        if (ready) queryMsTotal += (System.nanoTime() - queryStart) / 1e6

                        if (ring.fill >= targetFillFrames || ring.room < DoughEngine.BLOCK_SIZE) {
                            // The ring is full for the first time, so the prefill is done. Until
                            // now this thread has had no deadline, and that is exactly what it
                            // needed: the first blocks through GraalWasm and Steam Audio run
                            // interpreted, and one of them was measured at 365 ms. Declaring
                            // readiness after a single block instead -- which is what this used to
                            // do -- hands the audio thread a nearly empty ring and a render loop
                            // that has not been compiled yet, and it underran 0.78 s out of 4.
                            if (!ready) {
                                // The warmup's own numbers would misdescribe steady-state play.
                                worstBlockMs = 0.0
                                ring.resetStatistics()
                                ready = true
                            }
                            // Ahead of the audio thread. Sleeping here is the whole margin.
                            runCatching { Thread.sleep(2L) }.onFailure { return }
                            continue
                        }

                        val started = System.nanoTime()
                        val blockStart = frames.get()
                        val blockEnd = blockStart + DoughEngine.BLOCK_SIZE

                        // 2. Start everything due in this block. Dough applies an event at its next
                        //    dsp(), so "due in this block" is as fine as the granularity gets.
                        while (pending.isNotEmpty() && pending.peek().startFrame < blockEnd) {
                            val event = pending.poll()
                            val source = sourceFor(context, settings, hrtf, arena, event.sourceKey, late = true)
                            if (source != null) {
                                source.engine.evaluate(event.doughEvent)
                                source.target = event.placement
                                source.notesPlayed++
                            }
                        }
                        val afterEvents = System.nanoTime()

                        // 3. One block per source, spatialised and summed.
                        java.util.Arrays.fill(mixL, 0f)
                        java.util.Arrays.fill(mixR, 0f)
                        val live = synchronized(sourcesByKey) { sourcesByKey.values.toList() }
                        for (source in live) {
                            val t0 = System.nanoTime()
                            source.engine.renderBlock(source.blockL, source.blockR)
                            val t1 = System.nanoTime()
                            spatialiseSource(context, source, mixL, mixR, lane)
                            val t2 = System.nanoTime()
                            if (ready) {
                                synthMsTotal += (t1 - t0) / 1e6
                                spatialiseMsTotal += (t2 - t1) / 1e6
                            }
                        }

                        // Publish the voice counts for anyone watching. Only this thread may ask
                        // the engine, and asking costs a polyglot context enter, so it is periodic
                        // rather than per block.
                        if (blockStart % (DoughEngine.BLOCK_SIZE * 4L) == 0L) {
                            for (source in live) {
                                val v = source.engine.activeVoices() ?: 0
                                source.activeVoices = v
                                if (v > source.peakVoices) source.peakVoices = v
                            }
                        }

                        ring.write(mixL, mixR, DoughEngine.BLOCK_SIZE)
                        frames.set(blockEnd)

                        val finished = System.nanoTime()
                        val ms = (finished - started) / 1e6
                        if (ready) {
                            if (ms > worstBlockMs) worstBlockMs = ms
                            blocksRendered++
                            eventMsTotal += (afterEvents - started) / 1e6
                            if (ms > 5.0) {
                                slowBlocks++
                                slowBlockMsTotal += ms
                                if (ms > 50.0) stallBlocks++
                            }
                        }
                    }
                }
            }
        }
    }

    private fun spatialiseSource(
        context: SteamAudioContext,
        source: PatSpatialSource,
        mixL: FloatArray,
        mixR: FloatArray,
        lane: FloatArray,
    ) {
        // pan/0 put every voice in the left channel, so this copy is the mono source signal and
        // not a downmix of one.
        System.arraycopy(source.blockL, 0, source.mono, 0, DoughEngine.BLOCK_SIZE)
        source.dry.writeChannel(0, source.mono)

        val at = approach(source.current, source.target)
        source.current = at

        // The placement is an anchor in the ROOM, not an offset from the head: az/elev/dist are
        // spherical coordinates about the room's origin. With the listener at that origin and level
        // -- which is every pattern that never mentions a pose -- the two readings agree exactly,
        // and they part company as soon as a head moves.
        val anchor = at.direction()
        val radius = at.distance
        val relX = anchor[0] * radius - listenerPose.x
        val relY = anchor[1] * radius - listenerPose.y
        val relZ = anchor[2] * radius - listenerPose.z

        // Steam Audio's own inverse-distance curve, evaluated here rather than through
        // DistanceAttenuationModel.calculate, which allocates a scratch Arena per call.
        val separation = sqrt(relX * relX + relY * relY + relZ * relZ)
        val distance = max(separation, PatSpatialPlacement.MIN_DISTANCE)
        source.directParams.distanceAttenuation =
            (PatSpatialPlacement.MIN_DISTANCE / distance).toFloat()
        source.directParams.occlusion = (1.0 - at.occlusion).toFloat()

        var flags = DirectEffectFlags.APPLY_DISTANCE_ATTENUATION or DirectEffectFlags.APPLY_OCCLUSION
        if (at.airAbsorption) {
            // Steam Audio's default air-absorption curve: a per-band exponential in distance.
            for (band in source.airGains.indices) {
                val coeff = AIR_COEFFS[band.coerceAtMost(AIR_COEFFS.size - 1)]
                source.airGains[band] = Math.exp(-coeff * distance).toFloat()
            }
            source.directParams.setAirAbsorption(source.airGains)
            flags = flags or DirectEffectFlags.APPLY_AIR_ABSORPTION
        }
        source.directParams.flags = flags
        source.direct.apply(source.directParams, source.dry, source.shaped)

        // The head's rotation turns the world the other way, which is the whole content of a head
        // rotation: a source ahead of a head turned right is off to the left. Rotating the room's
        // offset by the inverse of the head's orientation says that for pitch and roll too, which
        // an azimuth subtraction cannot.
        val inv = 1.0 / max(separation, 1e-6)
        val wx = (relX * inv).toFloat()
        val wy = (relY * inv).toFloat()
        val wz = (relZ * inv).toFloat()
        val m = worldToHead
        source.binauralParams.setDirection(
            m[0] * wx + m[1] * wy + m[2] * wz,
            m[3] * wx + m[4] * wy + m[5] * wz,
            m[6] * wx + m[7] * wy + m[8] * wz,
        )
        source.binaural.apply(source.binauralParams, source.shaped, source.stereo)

        source.stereo.interleaveTo(context, lane)
        for (f in 0 until DoughEngine.BLOCK_SIZE) {
            mixL[f] += lane[f * 2]
            mixR[f] += lane[f * 2 + 1]
        }
    }

One pole per block toward the target, so a placement jump does not step the HRTF filter.

    private fun approach(from: PatSpatialPlacement, to: PatSpatialPlacement): PatSpatialPlacement {
        if (from == to) return to
        val k = SMOOTHING
        fun lerp(a: Double, b: Double) = a + (b - a) * k
        // Azimuth is circular: going from -170 to 170 is 20 degrees the short way, not 340.
        var delta = (to.azimuthDeg - from.azimuthDeg) % 360.0
        if (delta > 180.0) delta -= 360.0
        if (delta < -180.0) delta += 360.0
        return PatSpatialPlacement(
            azimuthDeg = from.azimuthDeg + delta * k,
            elevationDeg = lerp(from.elevationDeg, to.elevationDeg),
            distance = lerp(from.distance, to.distance),
            occlusion = lerp(from.occlusion, to.occlusion),
            airAbsorption = to.airAbsorption,
        )
    }

The distinct spat tags in the pattern's first prescanCycles cycles, in first-seen order.

Queries the pattern the same way the render loop does, so a tag that only exists under a condition the first cycles do not reach is genuinely not knowable here -- that is the case lateSourceBoots counts rather than pretends to have solved.

source — 45 lines, from line 952
    private fun prescanSourceKeys(): List<String> {
        val keys = LinkedHashSet<String>()
        for (c in 0 until prescanCycles) {
            if (keys.size >= maxSources) break
            val events = runCatching {
                patSpatialEventsForArc(
                    pattern = pattern, arc = PatArc(PatQ.of(c.toLong()), PatQ.of(c.toLong() + 1)),
                    cps = cps, sampleRate = sampleRate, gain = gain, defaultSound = defaultSound,
                )
            }.getOrElse { emptyList() }
            for (e in events) keys.add(e.sourceKey)
        }
        // A pattern with no events at all in its opening cycles still gets the default source, so
        // that a first note arriving later does not pay for a boot.
        if (keys.isEmpty()) keys.add("0")
        return keys.take(maxSources)
    }

    private fun sourceFor(
        context: SteamAudioContext,
        settings: AudioSettings,
        hrtf: Hrtf,
        arena: Arena,
        key: String,
        late: Boolean,
    ): PatSpatialSource? {
        synchronized(sourcesByKey) {
            sourcesByKey[key]?.let { return it }
            if (sourcesByKey.size >= maxSources) return null
        }
        if (late) lateSourceBoots++
        val source = PatSpatialSource(
            key = key,
            engine = DoughEngine(wasmPath = wasmPath, sampleRate = sampleRate),
            direct = DirectEffect(context, settings, channelCount = 1),
            binaural = BinauralEffect(context, settings, hrtf),
            directParams = DirectEffectParams(arena, flags = DirectEffectFlags.APPLY_DISTANCE_ATTENUATION),
            binauralParams = BinauralEffectParams(arena, hrtf = hrtf, interpolation = hrtfInterpolation),
            dry = AudioBuffer(arena, 1, DoughEngine.BLOCK_SIZE),
            shaped = AudioBuffer(arena, 1, DoughEngine.BLOCK_SIZE),
            stereo = AudioBuffer(arena, 2, DoughEngine.BLOCK_SIZE),
        )
        synchronized(sourcesByKey) { sourcesByKey[key] = source }
        return source
    }

fun stop()

    fun stop() {
        if (!running.compareAndSet(true, false)) return
        thread?.interrupt()
        thread = null
    }

val isRunning: Boolean get() = running.get()

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this, "cps" to cps, "sources" to sources.size, "cycles" to cyclesQueried,
        "fill" to ring.fill, "underruns" to ring.underruns, "worstBlockMs" to worstBlockMs,
    )

    private companion object {
        /** Fraction of the remaining distance a block closes. 0.02 is about 5 ms at 128/44100. */
        const val SMOOTHING = 0.02

        /**
         * Air absorption per metre, per Steam Audio band (low, mid, high).
         *
         * These are the coefficients Steam Audio's own default model uses, and they are here rather
         * than behind `IPLAirAbsorptionModel` because evaluating that model allocates.
         */
        val AIR_COEFFS = doubleArrayOf(0.0002, 0.0017, 0.0182)
    }
}

class PatSpatialBank internal constructor

The audio thread's end of the chain: drain a PatSpatialRing into the output.

There is nothing else in here on purpose. No synthesis, no spatialisation, no allocation, no locks -- a bounded copy and two monotonic counters. Everything expensive is a thread away.

class PatSpatialBank internal constructor(
    val ring: PatSpatialRing,
    val sampleRate: Int,
    val masterGain: Double = 1.0,
) : StereoNode() {

    private val frameCounter = AtomicLong(0L)

internal var isReady: () -> Boolean = { true }

Until this says ready the bank plays silence and leaves the ring alone. A consumer attached before the render thread has warmed up would otherwise drain a ring the render cannot yet keep full; set by patSpatial to the room's PatSpatialRoom.ready.

    @Volatile
    internal var isReady: () -> Boolean = { true }

val frame: Long get() = frameCounter.get()

Frames the audio thread has pulled.

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

    override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
        if (!isReady()) return
        ring.readInto(outL, outR, numFrames)
        if (masterGain != 1.0) {
            val g = masterGain.toFloat()
            for (i in 0 until numFrames) {
                outL[i] *= g
                outR[i] *= g
            }
        }
        frameCounter.set(frameCounter.get() + numFrames)
    }

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this, "frame" to frame, "fill" to ring.fill, "underruns" to ring.underruns,
    )
}

class PatSpatialLaunch internal constructor

A spatialised pattern, as one thing a box can return.

Returning it from a box ties the sound to the box, the way PatSynthLaunch does: the box stays lit while it plays, and stopping it stops the render thread and closes every Dough engine and Steam Audio handle with it.

tap sits in the signal path and passes audio through unchanged, so launch.tap.snapshot() is a copy of what the audio thread last produced rather than a second rendering.

class PatSpatialLaunch internal constructor(
    val bank: PatSpatialBank,
    val room: PatSpatialRoom,
) : StereoNode(), AutoCloseable {

val tap: StereoScope = StereoScope(bank)

A tap on this launch's own output, fed from the audio thread.

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

    override fun generateStereo(outL: FloatArray, outR: FloatArray, numFrames: Int, ctx: SynthContext) {
        tap.render(outL, outR, numFrames, ctx)
    }

val sources: List<PatSpatialSource> get() = room.sources

The spatial sources the pattern's spat tags have brought into being.

fun turnListener(azimuthDeg: Double) = room.turnListener(azimuthDeg)

Turn the listener's head. Positive is to the right.

fun setListenerPose(pose: HeadPose) = room.setListenerPose(pose)

Move and turn the listener's head. See PatSpatialRoom.setListenerPose.

fun setListenerPoseAt(poseAt: ((frame: Long) -> HeadPose)?) = room.setListenerPoseAt(poseAt)

A pose per render frame, for a scripted head. See PatSpatialRoom.setListenerPoseAt.

fun followHead

Drive the listener from a tracked head -- an OpenXR headset, or anything else that reports a pose. The returned tracker is started; closing it stops following, and closing this launch does not.

    fun followHead(
        head: HeadPoseSource,
        hz: Double = HeadTrackedListener.DEFAULT_HZ,
    ): HeadTrackedListener = HeadTrackedListener(room, head, hz).also { it.start() }

val underruns: Long get() = room.ring.underruns

Frames the audio thread asked for and the ring could not supply. Zero is the whole point.

fun awaitReady(timeoutMs: Long = 20_000L): Boolean

Block until the render thread has Steam Audio up and has filled the ring, or timeoutMs.

A launch is deliberately asynchronous -- booting one Dough engine per source takes a few hundred milliseconds each and a box should not sit on the main thread for it. A test, or a capture, does want to wait.

Ready means the ring has filled to PatSpatialRoom.targetFillFrames once, which is both the prefill and the render loop's warmup. A consumer attached earlier does no harm: the bank plays silence until then rather than draining the ring.

    fun awaitReady(timeoutMs: Long = 20_000L): Boolean {
        val deadline = System.nanoTime() + timeoutMs * 1_000_000L
        while (System.nanoTime() < deadline) {
            room.failure?.let { throw IllegalStateException("spatial render thread failed", it) }
            if (room.ready) return true
            Thread.sleep(5L)
        }
        return false
    }

fun advanceOffline(frames: Int)

Pull frames through the bank and into tap, for when nothing else is pulling it.

The same situation PatSynthLaunch.advanceOffline answers: a machine with no output device has no audio thread, so nobody drains the ring and the tap stays empty. The difference is that here the render thread is running regardless, so this does not advance the synthesis -- it only consumes what the render thread has produced, waiting whenever the ring is short. That thread is paced by the ring rather than by the clock (it sleeps only at PatSpatialRoom.targetFillFrames), so with nothing else draining the ring a render runs as fast as the two threads go: HeadTrackedPatternDemo measures 7x to 30x realtime.

    fun advanceOffline(frames: Int) {
        require(frames >= 0) { "frames must be >= 0, got $frames" }
        // The bank is silent until the room is ready, so an offline pull before then would record silence.
        if (frames > 0) awaitReady()
        var left = frames
        while (left > 0) {
            val n = minOf(OFFLINE_BLOCK, left)
            while (room.ring.fill < n && room.isRunning) {
                room.failure?.let { throw IllegalStateException("spatial render thread failed", it) }
                Thread.sleep(1L)
            }
            java.util.Arrays.fill(offlineL, 0f)
            java.util.Arrays.fill(offlineR, 0f)
            offlineContext.cycleId = ++offlineCycle
            offlineContext.time = bank.frame.toFloat() / bank.sampleRate
            tap.render(offlineL, offlineR, n, offlineContext)
            left -= n
        }
    }

    private val offlineContext = SynthContext(bank.sampleRate.toFloat(), OFFLINE_BLOCK)
    private val offlineL = FloatArray(OFFLINE_BLOCK)
    private val offlineR = FloatArray(OFFLINE_BLOCK)
    private var offlineCycle = 0L

override fun close()

    override fun close() {
        room.stop()
    }

override fun toString(): String = synthPrettyObject(this, "room" to room, "bank" to bank)

    private companion object {
        const val OFFLINE_BLOCK = 2048
    }
}

fun patSpatial

Play pattern through Dough, placed in space by Steam Audio, in realtime.

audio {
    patSpatial(
        pat("c4 e4 g4 b4").with(
            paramOf("spat", pat("<near far>")),
            param("az", saw().range(-80.0, 80.0)),
            param("dist", "1 4"),
        ),
        cps = 1.5,
    )
}

Headphones. HRTF rendering assumes each ear hears only its own channel; over speakers it collapses to something close to ordinary panning.

@param cps cycles per second. @param gain a multiplier on top of each event's own velocity. @param sampleRate must match the engine that will play this, and the default matches FieldAudioEngine.shared. The ring has a producer on one clock and a consumer on the other, so a mismatch is not a resampling -- it is a steady drift that empties or overflows the ring and transposes everything on the way. registerPatSpatialReturnValue refuses a mismatch rather than letting it be heard as a tuning problem. @param maxSources how many distinct spat tags get an engine. Each costs a Dough boot and about 200 MB of wasm memory, so this is a real ceiling rather than a formality. @param prescanCycles how many opening cycles are searched for spat tags, so that their Dough engines are booted before the first sample rather than inside a block that owes the audio thread 2.9 ms. See PatSpatialRoom.lateSourceBoots for what happens to a tag that appears later than this. @param ringFrames headroom between the render thread and the audio thread, and therefore also this path's added output latency. The default holds 341 ms at 48 kHz and the render thread aims to keep 256 ms of it filled. That is far more than the 12.7 ms worst block measured on an idle machine, and deliberately so: the stalls that matter are not this code's arithmetic but the scheduler's, and on a machine at three times its core count they ran to 115 ms. Latency here costs nothing musically -- the pattern is queried ahead, so notes stay in time with each other; what it delays is only how soon a start or a stop is heard. @param listenerPoseAt the listener's pose as a function of the render frame, from the first block on. See PatSpatialRoom.setListenerPoseAt.

fun patSpatial(
    pattern: PatPattern<String>,
    cps: Double = 1.0,
    gain: Double = 1.0,
    sampleRate: Int = 48_000,
    maxSources: Int = 4,
    ringFrames: Int = 16384,
    targetFillFrames: Int = 12288,
    masterGain: Double = 1.0,
    hrtfInterpolation: HrtfInterpolation = HrtfInterpolation.BILINEAR,
    defaultSound: String = "saw",
    prescanCycles: Int = 8,
    wasmPath: Path = Paths.get("./src/jvmMain/resources/dough.wasm"),
    listenerPoseAt: ((frame: Long) -> HeadPose)? = null,
): PatSpatialLaunch {
    require(maxSources in 1..16) { "maxSources must be in 1..16, got $maxSources" }
    val ring = PatSpatialRing(ringFrames)
    require(targetFillFrames in DoughEngine.BLOCK_SIZE until ring.capacityFrames) {
        "targetFillFrames must be between one block and the ring capacity " +
            "(${DoughEngine.BLOCK_SIZE} until ${ring.capacityFrames}), got $targetFillFrames"
    }
    val bank = PatSpatialBank(ring = ring, sampleRate = sampleRate, masterGain = masterGain)
    val room = PatSpatialRoom(
        pattern = pattern,
        cps = cps,
        sampleRate = sampleRate,
        gain = gain,
        ring = ring,
        wasmPath = wasmPath,
        maxSources = maxSources,
        targetFillFrames = targetFillFrames,
        hrtfInterpolation = hrtfInterpolation,
        defaultSound = defaultSound,
        prescanCycles = prescanCycles,
        listenerPoseAt = listenerPoseAt,
    )
    bank.isReady = { room.ready }
    room.start()
    return PatSpatialLaunch(bank, room)
}

fun registerPatSpatialReturnValue()

Teach Field that a PatSpatialLaunch is something a box can be running.

registerPatSynthReturnValue's shape exactly: register the patch, hold the box open while the run generation is current, and close the launch in a finally so the render thread, the Dough engines and the Steam Audio handles all go with it.

@RunsAt(returnValueRegistration, priority = 10)
fun registerPatSpatialReturnValue() {
    ReturnValueRegistry.handlers += handler@{ inside, returnValue, strand, generation, launchEpoch, notify ->
        val launch = returnValue as? PatSpatialLaunch ?: return@handler false

        val engine = FieldAudioEngine.shared()
        // A ring whose producer and consumer disagree about the sample rate does not resample; it
        // drifts. At 44.1 against 48 the audio thread consumes 9% faster than the render thread
        // produces, so the ring empties, and what does arrive is transposed up a tone and a half.
        // Both symptoms read as a synth problem rather than a clock problem, so refuse it here.
        require(launch.bank.sampleRate == engine.sampleRate) {
            "patSpatial(sampleRate = ${launch.bank.sampleRate}) cannot play on a " +
                "FieldAudioEngine running at ${engine.sampleRate} Hz -- the ring would drift. " +
                "Pass sampleRate = ${engine.sampleRate}."
        }
        val handle = engine.register(audio(launch, gain = 1f))

        inMain {
            val job = kotlin.coroutines.coroutineContext[Job]!!
            if (!inside.registerLaunchJob(job, launchEpoch)) {
                handle.close()
                launch.close()
                notify(false, strand, generation)
                return@inMain
            }
            notify(true, strand, generation)
            try {
                while (inside.runGeneration[strand] == generation) {
                    yield()
                }
            } finally {
                handle.close()
                launch.close()
                inside.jobs.remove(job)
                notify(false, strand, generation)
            }
        }
        true
    }
}

fun Box.showPatSpatial

Play pattern in space and put a live waveform and spectrum in this box's output.

Return the result as the box's last expression, as with patSpatial: the display is a side effect and the launch is what ties the sound to the box.

fun Box.showPatSpatial(
    pattern: PatPattern<String>,
    cps: Double = 1.0,
    channel: String = "main",
): PatSpatialLaunch {
    val launch = patSpatial(pattern, cps = cps)
    launch.tap.liveSummary(channel)
    return launch
}