Skip to content

StrokeSketch.kt

src/jvmMain/kotlin/afield/ui/notation/StrokeSketch.kt · 441 lines

package afield.ui.notation

import afield.app.protocol.Handled
import afield.app.protocol.Message
import afield.app.protocol._send
import afield.app.protocol.handlers
import afield.app.protocol.payload
import afield.app.server.SessionInformation
import afield.app.server.thisSession
import afield.app.server.thisSessionInfo
import afield.audio.synth.DoughLive
import afield.audio.synth.PatParam
import afield.audio.synth.PatPattern
import afield.audio.synth.param
import afield.audio.synth.paramOf
import afield.audio.synth.pure
import afield.audio.synth.sig
import afield.document.graph.Box
import afield.document.graph.registerStopHook
import afield.geometry.path.FLine
import afield.math.linalg.Vec2
import afield.math.linalg.vec
import afield.runtime.lifecycle.AnimationClock
import afield.runtime.lifecycle.RunsAt
import afield.runtime.lifecycle.inMain
import afield.runtime.lifecycle.plugins
import io.ktor.websocket.WebSocketSession
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonPrimitive
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min

Living strokes: each stroke drawn by hand on the drawing layer becomes an animated, sounding object running code of its own, after the madp line demo.

A box takes the pen with eachStroke. Each stroke then drawn with the drawing layer's pencil is taken off the scene and becomes a Stroke: physics points that start where the hand went, the block of the box that held the pen when it was drawn (later edits do not reach it), a draw { t -> } called sixty times a second, and an optional pattern playing through dough. What the strokes draw is shown on the stroke layer, a canvas in world coordinates between Field's background and its boxes, which fades towards transparent for trails.

One StrokeSketch per document keeps the strokes, the pen and the clock. Its frames go to each window showing the document as stroke-layer-frame, one at a time: the next is sent once the window answers stroke-layer-ack, so a slow window is never flooded.

class StrokePoint(var x: Double, var y: Double)

One physics point of a living stroke. Set ax and ay in draw { }; the sketch integrates them.

var vx = 0.0

var vy = 0.0

var ax = 0.0

var ay = 0.0

val at: Vec2 get() = vec(x, y)

Where the point is now.

override fun toString() = "StrokePoint(%.1f, %.1f)".format(x, y)

data class StrokeHit(val x: Double, val y: Double, val dist: Double, val strokeIndex: Int, val pointIndex: Int)

A point another stroke has, found by Stroke.closestPoint or Stroke.closestOutput.

data class StrokeHit(val x: Double, val y: Double, val dist: Double, val strokeIndex: Int, val pointIndex: Int)

class StrokePen internal constructor

What eachStroke made: the block a box gives to every stroke drawn while it holds the pen.

class StrokePen internal constructor(
    val box: Box,
    /** How much of the last frame each frame keeps, 0..1: 0 clears, 0.9 leaves long trails. */
    val trail: Double,
    /** Width of a line that does not set its own `thickness`. */
    val lineWidth: Double,
    internal val body: Stroke.() -> Unit,
) {

internal val sketch: StrokeSketch? get()

    internal val sketch: StrokeSketch? get() = if (box.isInsideInitialized()) StrokeSketch.of(box.inside) else null

val hasPen: Boolean get() = sketch?.pen === this

Whether strokes drawn now come to this pen.

override fun toString()

    override fun toString() = "pen of '${box.name}' (${if (hasPen) "holding it" else "put down"}, ${sketch?.strokes?.size ?: 0} strokes on the page)"
}

class Stroke internal constructor

A stroke that has come alive: its points, its place in the drawing (index), and the code it was drawn with. The receiver of the eachStroke block, which runs once when the stroke is drawn (the demo's onStart) and sets up the rest with draw (its manipulatePoints) and sound.

class Stroke internal constructor(
    internal val sketch: StrokeSketch,
    val pen: StrokePen,
    /** 0 for the first stroke on the page, then 1, 2, ... */
    val index: Int,
    /** The stroke's physics points, where the hand went, about 2 units apart. */
    val points: List<StrokePoint>,
    internal val born: Double,
) : StrokeVerbs() {

val box: Box get() = pen.box

The box that held the pen when this stroke was drawn.

var t: Double

Seconds since this stroke was drawn.

    var t: Double = 0.0
        internal set

internal var drawer: ((Double) -> List<FLine>)? = null

internal var output: List<FLine> = emptyList()

internal var indexed: FloatArray = FloatArray(0)

internal var voice: DoughLive.PatternVoice? = null

internal var lastError: String? = null

val curve: List<Vec2> get() = points.map { it.at }

The points' current positions, as a curve.

fun draw(block: (t: Double) -> List<FLine>) {

What this stroke draws, called 60 times a second with t, seconds since it was drawn. Return the lines to show; set ax/ay on points to push them, and the sketch moves them after the call (damping 0.98 a step). Without a draw, the stroke is shown as it was drawn.

    fun draw(block: (t: Double) -> List<FLine>) {
        drawer = block
    }

fun sound(pattern: PatPattern<String>)

Play pattern while this stroke lives, on the sketch's clock (0.5 cycles a second, shared by every stroke, so patterns stay in step). The pattern language's own atoms and params, with the Strudel names s, lpf and signal: pat("c4 e4 g4") with s("sawtooth") with gain(signal { level }). Note names are Tidal's, so c5 is middle C. Gains are Strudel's: the pattern plays at a quarter of dough's level, as quiet as the same gain is in the demo.

    fun sound(pattern: PatPattern<String>) {
        voice?.stop()
        voice = sketch.play(this, pattern)
    }

val cycle: Double get() = sketch.cycle

The sketch's sound clock, in cycles: cycle % 1 is how far through the bar it is now.

fun closestPoint(x: Double, y: Double): StrokeHit?

The nearest physics point of any other stroke to (x, y), or null when this is the only one.

    fun closestPoint(x: Double, y: Double): StrokeHit? {
        var best: StrokeHit? = null
        for (other in sketch.strokes) {
            if (other === this) continue
            other.points.forEachIndexed { j, p ->
                val d = hypot(x - p.x, y - p.y)
                if (best == null || d < best.dist) best = StrokeHit(p.x, p.y, d, other.index, j)
            }
        }
        return best
    }

fun closestOutput(x: Double, y: Double): StrokeHit?

The nearest point of what any other stroke drew last frame to (x, y): every node of its lines, except lines marked FLine.ignore. Last frame's, because this frame's is still being drawn.

    fun closestOutput(x: Double, y: Double): StrokeHit? {
        var best: StrokeHit? = null
        for (other in sketch.strokes) {
            if (other === this) continue
            val a = other.indexed
            var j = 0
            while (j < a.size) {
                val px = a[j].toDouble()
                val py = a[j + 1].toDouble()
                val d = hypot(x - px, y - py)
                if (best == null || d < best.dist) best = StrokeHit(px, py, d, other.index, j / 2)
                j += 2
            }
        }
        return best
    }

fun rms(): Double = (DoughLive.sharedOrNull()?.mixLevel ?: 0.0) * 0.7071

How loud everything the strokes play is right now, measured from dough's voices: about 0.7 for a sine at gain 1.

fun s(name: String): PatParam = paramOf("s", pure(name))

Strudel's .sound(name) / s(name): the dough sound a note plays on (sawtooth, triangle, square, sine).

fun lpf(values: PatPattern<Double>): PatParam = param("lpf", values)

Strudel's .lpf(hz): dough's low-pass cutoff.

fun lpf(hz: Number): PatParam = param("lpf", hz)

lpf at a fixed cutoff.

fun signal(read: () -> Double): PatPattern<Double> = sig { read() }

Strudel's signal(() => …): a continuous pattern whose value is read when each event is scheduled.

override fun toString()

    override fun toString() = "stroke $index of '${box.name}' (${points.size} points, %.1fs old)".format(t)
}

fun Box.eachStroke(trail: Double = 0.0, lineWidth: Double = 2.0, keepMarks: Boolean = false, body: Stroke.() -> Unit): StrokePen {

Take the pen: every stroke drawn by hand from now on (Alt+D, the pencil) becomes a living Stroke running body.

eachStroke(trail = 0.18) {
    var energy = 0.0
    sound(pat("c4 e4 g4") with s("triangle") with gain(signal { 0.03 + energy * 0.12 }))
    draw { t ->
        val base = resample(points, 48)
        val wave = base.indices.map { i ->
            val n = normalAt(base, i)
            val w = driftNoise(i * 0.2, t, 1.0) * 18
            vec(base[i].x + n.x * w, base[i].y + n.y * w)
        }
        energy = clamp01(t / 10)
        listOf(smooth(wave).also { it.color = cssHsla(200 + t * 20, 0.9, 0.68, 0.88) })
    }
}

body is the stroke's own program: it runs once when the stroke is drawn, and what it keeps in local variables is that stroke's alone and lives as long as the stroke, shared by its drawing and its sound (the demo's ctrl). Running this box again changes what new strokes do; strokes already drawn keep the code they were drawn with. Running another eachStroke box gives that box the pen; stopping this one puts it down. trail (0..1) is how much of each frame the next keeps, and lineWidth is the width of lines that set no thickness. clearStrokes removes every stroke.

The hand-drawn mark is removed from the drawing as the stroke comes alive, unless keepMarks.

fun Box.eachStroke(trail: Double = 0.0, lineWidth: Double = 2.0, keepMarks: Boolean = false, body: Stroke.() -> Unit): StrokePen {
    val pen = StrokePen(this, trail.coerceIn(0.0, 1.0), lineWidth, body)
    val sketch = pen.sketch ?: throw IllegalStateException("eachStroke needs a box in a document")
    sketch.pen = pen
    sketch.trail = pen.trail
    notation.onStroke("eachStroke") { mark -> if (sketch.pen === pen) sketch.take(pen, mark, keepMarks) }
    registerStopHook(Runnable { if (sketch.pen === pen) sketch.pen = null })
    sketch.ensureRunning()
    return pen
}

fun Box.clearStrokes()

Remove every living stroke in this document, and silence them. The pen stays where it is.

fun Box.clearStrokes() {
    if (isInsideInitialized()) StrokeSketch.of(inside).clear()
}

val Box.livingStrokes: List<Stroke>

The living strokes of this document, oldest first.

val Box.livingStrokes: List<Stroke>
    get() = if (isInsideInitialized()) StrokeSketch.of(inside).strokes.toList() else emptyList()

fun installStrokeLayerHandlers()

The server end of the stroke layer: a window answers each stroke-layer-frame with stroke-layer-ack once it has drawn it, and only then is it sent the next.

@RunsAt(plugins)
fun installStrokeLayerHandlers() {
    handlers["stroke-layer-ack"] = mutableSetOf({ _, _ ->
        val info = thisSessionInfo.get()
        val session = thisSession.get()
        if (info != null && session != null) StrokeSketch.existing(info)?.acked(session)
        Handled.yes
    })
}

class StrokeSketch internal constructor(val info: SessionInformation)

The strokes of one document, the pen that new strokes go to, and the clock that steps them.

Stepping runs on the main loop at a fixed 60 steps a second (at most two per loop turn, so a stall does not become a burst), which is the rate the demo's physics constants were written for.

companion object

    companion object {
        private val sketches = ConcurrentHashMap<SessionInformation, StrokeSketch>()

fun of(info: SessionInformation): StrokeSketch

The sketch of the document info is.

        fun of(info: SessionInformation): StrokeSketch = sketches.computeIfAbsent(info) { StrokeSketch(it) }

internal const val STEP = 1.0 / 60

internal const val DAMPING = 0.98

internal const val CPS = 0.5

internal const val STRUDEL_LEVEL = 0.25

The level stroke patterns play at, relative to dough at the same gain: Strudel's synths are that much quieter than dough's, so the demo's gains sound as loud here as there, and its rms()-driven drawing moves as much. Measured on the prism choir with the same three strokes: the demo's rms() read 0.046 in Chrome, this sketch's 0.19 at gain 1.

internal fun existing(info: SessionInformation): StrokeSketch? = sketches[info]

val strokes = CopyOnWriteArrayList<Stroke>()

The strokes, oldest first.

var pen: StrokePen?

Where new strokes go, or null when no box holds the pen.

    @Volatile
    var pen: StrokePen? = null

var trail: Double

The trail of the most recent pen: how much of each frame the next keeps.

    @Volatile
    var trail: Double = 0.0

    private val incoming = ConcurrentLinkedQueue<() -> Unit>()
    private val inFlight = ConcurrentHashMap<WebSocketSession, Double>()
    private var originFrame: Long? = null
    @Volatile
    private var running = false
    private var cleared = true

val cycle: Double

The sound clock, in cycles since the first stroke played; 0 before any did.

    val cycle: Double
        get() {
            val origin = originFrame ?: return 0.0
            val live = DoughLive.sharedOrNull() ?: return 0.0
            return max(0.0, (live.heardFrame - origin).toDouble() / live.sampleRate * CPS)
        }

internal fun play(stroke: Stroke, pattern: PatPattern<String>): DoughLive.PatternVoice

    internal fun play(stroke: Stroke, pattern: PatPattern<String>): DoughLive.PatternVoice {
        val live = DoughLive.shared()
        val origin = originFrame ?: live.framesRendered.also { originFrame = it }
        return live.play(pattern, CPS, DoughLive.groupOf(stroke.box), origin, gain = STRUDEL_LEVEL)
    }

internal fun take(pen: StrokePen, mark: Mark, keepMarks: Boolean)

    internal fun take(pen: StrokePen, mark: Mark, keepMarks: Boolean) {
        val raw = mark.points
        incoming += {
            val pts = ArrayList<StrokePoint>()
            for (p in raw) {
                val last = pts.lastOrNull()
                if (last == null || hypot(p.x - last.x, p.y - last.y) >= 2.0) pts += StrokePoint(p.x.toDouble(), p.y.toDouble())
            }
            if (pts.size >= 2) {
                if (!keepMarks) pen.box.notation.delete(mark)
                val stroke = Stroke(this, pen, strokes.size, pts, born = now)
                runUser(stroke) { stroke.(pen.body)() }
                strokes += stroke
            }
        }
        ensureRunning()
    }

internal fun clear()

    internal fun clear() {
        incoming += {
            strokes.forEach { it.voice?.stop() }
            strokes.clear()
            originFrame = null
        }
        ensureRunning()
    }

internal fun acked(session: WebSocketSession)

    internal fun acked(session: WebSocketSession) {
        inFlight.remove(session)
    }

internal fun ensureRunning()

    @Synchronized
    internal fun ensureRunning() {
        if (running) return
        running = true
        inMain {
            while (tick(AnimationClock.nowSeconds())) delay(4)
            synchronized(this@StrokeSketch) { running = false }
            publish()
        }
    }

    private var now = Double.NaN
    private var owed = 0.0

internal fun tick(at: Double): Boolean

One turn of the sketch's loop at time at (seconds): bring new strokes to life, take the 1/60 s steps owed since the last turn (two at most), and send the frame. False once there is nothing left to run: no strokes, no pen.

body — 72 lines, from line 366
    internal fun tick(at: Double): Boolean {
        val last = now
        now = at
        while (true) (incoming.poll() ?: break).invoke()
        if (!last.isNaN()) owed = min(2 * STEP, owed + max(0.0, at - last))
        var stepped = false
        // A turn exactly 1/60 s after the last can come out a hair short in floating point.
        while (owed >= STEP - 1e-9) {
            step()
            owed = max(0.0, owed - STEP)
            stepped = true
        }
        if (stepped) publish()
        return !(strokes.isEmpty() && pen == null && incoming.isEmpty())
    }

    private fun step() {
        val all = strokes.toList()
        for (s in all) for (p in s.points) {
            p.ax = 0.0
            p.ay = 0.0
        }
        val next = arrayOfNulls<FloatArray>(all.size)
        all.forEachIndexed { i, s ->
            s.t = now - s.born
            val drawer = s.drawer
            s.output = if (drawer == null) listOf(polylineOf(s)) else runUser(s) { drawer(s.t) } ?: s.output
            next[i] = StrokeLayerFrames.indexedPositions(s.output)
        }
        all.forEachIndexed { i, s ->
            s.indexed = next[i]!!
            for (p in s.points) {
                p.vx = (p.vx + p.ax * STEP) * DAMPING
                p.vy = (p.vy + p.ay * STEP) * DAMPING
                p.x += p.vx * STEP
                p.y += p.vy * STEP
            }
        }
    }

    private fun publish() {
        val sessions = info.sessions
        if (sessions.isEmpty()) return
        if (strokes.isEmpty() && cleared) return
        val frame = StrokeLayerFrames.encode(trail, strokes.map { it.output to (it.pen.lineWidth) })
        cleared = strokes.isEmpty()
        val now = AnimationClock.nowSeconds()
        val message = Message("stroke-layer-frame").payload(JsonPrimitive(frame))
        for (session in sessions) {
            val sentAt = inFlight[session]
            // A window that has not answered in a quarter of a second (hidden, or gone) gets frames at
            // that rate rather than none.
            if (sentAt != null && now - sentAt < 0.25 && !cleared) continue
            inFlight[session] = now
            _send(session, message)
        }
    }

    private fun polylineOf(s: Stroke): FLine = FLine().also { f ->
        s.points.forEachIndexed { i, p -> if (i == 0) f.moveTo(p.x, p.y) else f.lineTo(p.x, p.y) }
    }

    private fun <T> runUser(stroke: Stroke, block: () -> T): T? = try {
        Box.withBoxContextNow(stroke.box) { block() }.also { stroke.lastError = null }
    } catch (t: Throwable) {
        val message = t.message ?: t.toString()
        if (message != stroke.lastError) {
            stroke.lastError = message
            stroke.box.displayError("stroke ${stroke.index} threw: $message")
        }
        null
    }

override fun toString()

    override fun toString() = "stroke sketch of ${info.documentName}: ${strokes.size} strokes, pen ${pen?.box?.name ?: "down"}"
}