Skip to content

Score.kt

src/jvmMain/kotlin/afield/animation/motion/Score.kt · 476 lines

@file:afield.runtime.completion.CompletionCategory("Simulation")

package afield.animation.motion

import afield.app.protocol.Rect
import afield.audio.synth.sound
import afield.audio.synth.thisRMS
import afield.document.graph.Box
import afield.document.graph.registerStopHook
import afield.runtime.execution.begin
import afield.runtime.lifecycle.AnimationClock
import afield.runtime.lifecycle.Frames
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.loop
import afield.ui.output.displayOpen
import afield.ui.output.outputRequests
import kotlinx.serialization.json.JsonPrimitive
import java.util.WeakHashMap
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min

The canvas as a score: boxes are the events, a playhead box sweeps across them, and each box's score { } runs every frame the playhead is over it.

A box arms itself with score; the lead box starts the clock with playScore, which moves the box named playhead across a horizontal span of the canvas once per loop seconds and calls every armed box whose frame the playhead crosses. Inside score { } the receiver is a ScoreFrame: how far through the box the playhead is (ScoreFrame.localProgress, ScoreFrame.localT), whether it just arrived (ScoreFrame.justEntered), a ScoreFrame.draw list that the lead box's output panel shows as a canvas, dough sound (ScoreFrame.sound) and the box's measured level (ScoreFrame.thisRMS), and the box's own frame (ScoreFrame.box), which it may move.

This is Field's time slider (templates/timeSlider: a line box that starts the boxes it passes over) with a clock and per-frame calls instead of launches, so a box's code is compiled once, when it is run, rather than every time the playhead reaches it. The clock is AnimationClock, so a video recording of a score plays at the right speed.

private val armed = WeakHashMap<Box, ScoreBehaviour>()

class ScoreBehaviour internal constructor(val box: Box, internal val body: ScoreFrame.() -> Unit) {

What score arms: body, called with a fresh ScoreFrame each frame the playhead is over box.

internal var wasActive = false

internal val subdivisions = HashMap<String, Long>()

internal var lastError: String? = null

val isArmed: Boolean get() = synchronized(armed) { armed[box] === this }

False once the box has been stopped or has armed a newer behaviour.

override fun toString()

    override fun toString() = "score behaviour of '${box.name}' (${if (isArmed) "armed" else "disarmed"})"
}

fun Box.score(body: ScoreFrame.() -> Unit): ScoreBehaviour {

Arm this box: body runs every frame that a running playScore has its playhead over this box's frame.

score {
    val c = boxPoint(0.5, 0.5)
    draw.ring(c, 12 + thisRMS * 18 + tri01(localT * 2) * 8, color = "rgba(255,255,255,0.9)", width = 2.0)
    if (justEntered) sound("/dough/play/note/60/duration/0.08/s/sine/gain/0.2/lpf/1200/release/0.05")
}

Running the box again replaces the behaviour; stopping the box disarms it. The body is called on the main loop from the lead box's animation, with this box as the box in context, so print and display inside it go to this box. Anything declared above score { } lives as long as the behaviour, which is where state that must last between frames belongs (the demo's state). An exception is shown as an error on this box, once per distinct message, and the score carries on.

fun Box.score(body: ScoreFrame.() -> Unit): ScoreBehaviour {
    val behaviour = ScoreBehaviour(this, body)
    synchronized(armed) { armed[this] = behaviour }
    registerStopHook(Runnable {
        synchronized(armed) { if (armed[this] === behaviour) armed.remove(this) }
        if (behaviour.wasActive) tint = ""
    })
    return behaviour
}

Every armed box in owner's document.

private fun armedIn(owner: Box): List<ScoreBehaviour> = synchronized(armed) {
    armed.values.filter { it.box.isInsideInitialized() && owner.isInsideInitialized() && it.box.inside === owner.inside }
}

class ScoreTransport internal constructor

The clock of a running score: where the playhead is, and the span and loop it sweeps.

Made by playScore. paused holds the playhead where it is (boxes under it keep being called, with the clock stopped), and loopDuration may be changed while it runs.

class ScoreTransport internal constructor(
    val owner: Box,
    var loopDuration: Double,
    /** The canvas x span the playhead crosses once per loop. */
    val x: ClosedFloatingPointRange<Double>,
    /** The canvas y span the output panel shows, and the height the playhead box covers. */
    val y: ClosedFloatingPointRange<Double>,
    /** Output canvas width in pixels. */
    val width: Int,
    /** Output canvas height in pixels. x and y scale separately, as in a score whose time axis is long. */
    val height: Int,
    private val playheadName: String?,
    /** Whether a box is tinted ([Box.tint]) while the playhead is over it. */
    val tintActive: Boolean = true,
) {

var paused = false

Hold the clock.

var transportT

Seconds of score played, across loops.

    var transportT = 0.0
        private set

var frame

Frames stepped.

    var frame = 0
        private set

var playheadX

The playhead's canvas x.

    var playheadX = x.start
        private set

internal var lastFrameJson = "{\"w\":$width,\"h\":$height,\"calls\":[]}"

    private var lastNow = Double.NaN

    private val scoreWidth get() = x.endInclusive - x.start

internal fun step()

body — 43 lines, from line 130
    internal fun step() {
        val now = AnimationClock.nowSeconds()
        val dt = if (lastNow.isNaN()) 0.0 else min(0.05, max(0.0, now - lastNow))
        lastNow = now
        frame++
        if (!paused) transportT += dt
        val loop = max(1e-3, loopDuration)
        val loopIndex = floor(transportT / loop).toLong()
        val t = transportT - loopIndex * loop
        playheadX = x.start + scoreWidth * (t / loop)
        movePlayhead()

        val draw = ScoreDraw()
        for (b in armedIn(owner)) {
            val bounds = b.box.frame
            val active = playheadX >= bounds.x && playheadX <= bounds.x + bounds.w
            val entered = active && !b.wasActive
            val exited = !active && b.wasActive
            b.wasActive = active
            if (tintActive && entered) b.box.tint = playingTint(b.box)
            if (tintActive && exited) b.box.tint = ""
            if (!active && !exited) continue
            val localProgress = ((playheadX - bounds.x) / max(1.0, bounds.w)).coerceIn(0.0, 1.0)
            val f = ScoreFrame(
                this, b, draw, t = t, dt = dt, transportT = transportT, loopIndex = loopIndex,
                bounds = bounds, localProgress = localProgress,
                boxDuration = bounds.w / max(1.0, scoreWidth) * loop,
                justEntered = entered, justExited = exited,
            )
            try {
                Box.withBoxContextNow(b.box) { b.body(f) }
                b.lastError = null
            } catch (t: Throwable) {
                val message = t.message ?: t.toString()
                if (message != b.lastError) {
                    b.lastError = message
                    b.box.displayError("score { } threw: $message")
                }
            }
            if (exited) b.subdivisions.clear()
        }
        lastFrameJson = draw.toJson(width, height, t, loop)
    }

internal fun clearTints()

Untint every box this score lit, as it stops.

    internal fun clearTints() {
        for (b in armedIn(owner)) if (b.wasActive) {
            b.box.tint = ""
            b.wasActive = false
        }
    }

    private fun movePlayhead() {
        val name = playheadName ?: return
        val playhead = owner.root.children.firstOrNull { it.name == name } ?: return
        val f = playhead.frame
        val target = Rect(playheadX, y.start, f.w, y.endInclusive - y.start)
        if (abs(f.x - target.x) > 0.25 || f.y != target.y || f.h != target.h) playhead.frame = target
    }

override fun toString()

    override fun toString() = "score: %.2fs of %.2fs, playhead at x %.0f".format(transportT % loopDuration, loopDuration, playheadX)
}

fun Box.playScore

Start the score: sweep the box named playhead across x once every loop seconds, call every armed box (score) it crosses, and show what they draw in this box's output channel.

playScore(loop = 6.0, x = 0.0..1600.0, y = 100.0..700.0)

Make it the box's last expression: it returns the animation, and stopping the box stops the score. The output is a dark width by height canvas showing the canvas region x by y, so ScoreFrame.boxPoint lands a box's drawing where the box sits in the score. Every box whose frame lies in the score and that has not been run yet is run once at the start, the way Field's time slider runs the boxes it crosses, so running this one box plays the whole score. The playhead box, if there is one, is styled as an orange line and moved every frame; without one the score still plays. While the playhead is over a box the box is tinted (Box.tint) in a hue of its own, as the demo lights its active boxes; pass tint = false to leave boxes as they are.

fun Box.playScore(
    loop: Double = 4.0,
    x: ClosedFloatingPointRange<Double> = 0.0..1600.0,
    y: ClosedFloatingPointRange<Double> = 0.0..600.0,
    width: Int = 520,
    height: Int = 360,
    playhead: String? = "playhead",
    channel: String = "score",
    tint: Boolean = true,
): Frames {
    require(x.endInclusive > x.start && y.endInclusive > y.start) { "playScore needs a non-empty x and y span" }
    val transport = ScoreTransport(this, loop, x, y, width, height, playhead, tint)
    val binding = outputRequests("score-$channel") { JsonPrimitive(transport.lastFrameJson) }
    display(
        channel,
        """<iframe src="/score_view.html?binding=${binding.id}" """ +
            """style="border:0;display:block;background:#0a0a0a;width:${width}px;height:${height}px"></iframe>""",
    )
    displayOpen(channel)

    playhead?.let { name ->
        root.children.firstOrNull { it.name == name }?.let { p ->
            if (p.getPropertyRaw("customization") != PLAYHEAD_CUSTOMIZATION) p.setPropertyRaw("customization", PLAYHEAD_CUSTOMIZATION)
            p.frame = Rect(x.start, y.start, 12.0, y.endInclusive - y.start)
        }
    }
    // Like the time slider, which runs whatever it crosses: every box in the score that has not been
    // run yet is run once now, so opening a score and running this box plays all of it.
    val span = Rect(x.start, y.start, x.endInclusive - x.start, y.endInclusive - y.start)
    val waiting = synchronized(armed) {
        root.children.filter { it !== this && it.name != playhead && armed[it] == null && it.frame.intersects(span) }
    }
    waiting.forEach { it.begin() }

    return loop { transport.step() }.end { transport.clearTints() }
}

internal fun playingTint(box: Box): String

A box's playing tint: a hue fixed by its id, as the madp score demo gives each box its own.

internal fun playingTint(box: Box): String = "hsla(${Math.floorMod(box.id.hashCode() * 57, 360)}, 80%, 56%, 0.22)"

private val PLAYHEAD_CUSTOMIZATION = """
this.style.border = "0px solid transparent"
this.style.borderLeft = "2px solid #ffb86c"
this.style.background = "linear-gradient(90deg, rgba(255,184,108,0.10), transparent)"
this.style.setProperty("--box-border", "0")
this.style.color = "#ffb86c"
this.lock_w = true
this.lock_h = true
""".trimIndent()

class ScoreFrame internal constructor

One frame of one armed box: the receiver of score { }.

Positions are canvas coordinates, the same numbers as a box's frame; fromScore and boxPoint turn them into output-canvas pixels for draw. Times are in seconds.

class ScoreFrame internal constructor(
    private val transport: ScoreTransport,
    private val behaviour: ScoreBehaviour,
    /** The draw list every box under the playhead adds to this frame. */
    val draw: ScoreDraw,
    /** Seconds into the current loop. */
    val t: Double,
    /** Seconds since the last frame, at most 0.05. */
    val dt: Double,
    /** Seconds played, across loops. */
    val transportT: Double,
    /** Which loop this is, from 0. */
    val loopIndex: Long,
    /** This box's frame, in canvas coordinates. */
    val bounds: Rect,
    /** How far across this box the playhead is, 0 at its left edge and 1 at its right. */
    val localProgress: Double,
    /** Seconds the playhead takes to cross this box. */
    val boxDuration: Double,
    /** True on the first frame the playhead is over this box. */
    val justEntered: Boolean,
    /** True on the one extra call made the frame after the playhead leaves this box. */
    val justExited: Boolean,
) {
    private val owner: Box get() = behaviour.box

val localT: Double get() = localProgress * boxDuration

Seconds since the playhead reached this box: localProgress times boxDuration.

val loopDuration: Double get() = transport.loopDuration

Length of a loop, in seconds.

val progress: Double get() = t / loopDuration

How far through the loop the playhead is, 0 to 1.

val frame: Int get() = transport.frame

Frames the score has stepped.

val playheadX: Double get() = transport.playheadX

The playhead's canvas x.

val scoreLeft: Double get() = transport.x.start

The score's canvas span.

val scoreTop: Double get() = transport.y.start

val scoreRight: Double get() = transport.x.endInclusive

val scoreBottom: Double get() = transport.y.endInclusive

val scoreWidth: Double get() = scoreRight - scoreLeft

val scoreHeight: Double get() = scoreBottom - scoreTop

val width: Double get() = transport.width.toDouble()

The output canvas, in pixels.

val height: Double get() = transport.height.toDouble()

val scaleX: Double get() = width / scoreWidth

Output pixels per canvas unit.

val scaleY: Double get() = height / scoreHeight

val left: Double get() = bounds.x

val top: Double get() = bounds.y

val right: Double get() = bounds.x + bounds.w

val bottom: Double get() = bounds.y + bounds.h

val cx: Double get() = bounds.x + bounds.w / 2

val cy: Double get() = bounds.y + bounds.h / 2

fun fromScore(x: Double, y: Double)

The output-canvas point for canvas coordinates (x, y).

    fun fromScore(x: Double, y: Double) = ScorePoint((x - scoreLeft) * scaleX, (y - scoreTop) * scaleY)

fun boxPoint(u: Double, v: Double) = fromScore(bounds.x + bounds.w * u, bounds.y + bounds.h * v)

The output-canvas point at (u, v) across this box, 0..1 each way, (0, 0) its top left.

fun pt(x: Double, y: Double) = ScorePoint(x, y)

An output-canvas point, for ScoreDraw.polyline.

val thisRMS: Double get() = owner.thisRMS

This box's measured dough level: afield.audio.synth.thisRMS.

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

Play a dough path from this box; afield.audio.synth.sound.

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

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

Play a dough path from this box regardless of the last time it played.

fun onSubdivision(divisions: Int, key: String = "div:$divisions"): Boolean

True once each time the playhead crosses into another of divisions equal slices of this box, the first slice included: a pulse of divisions per pass. Keep a separate count per key.

    fun onSubdivision(divisions: Int, key: String = "div:$divisions"): Boolean {
        val n = max(1, divisions)
        val step = floor(localProgress.coerceIn(0.0, 0.999999) * n).toLong()
        val token = loopIndex * 1_000_003L + step
        if (behaviour.subdivisions[key] == token) return false
        behaviour.subdivisions[key] = token
        return true
    }

val box: ScoreBox get() = ScoreBox(owner)

This box's frame on the canvas, which the box may move while it plays.

fun clamp(v: Double, lo: Double, hi: Double) = max(lo, min(hi, v))

fun lerp(a: Double, b: Double, t: Double) = a + (b - a) * t

fun triWave(x: Double): Double

A triangle wave of period 1 between -1 and 1.

    fun triWave(x: Double): Double {
        val p = x - floor(x)
        return 1 - 4 * abs(p - 0.5)
    }

fun tri01(x: Double) = 1 - abs((x - floor(x)) * 2 - 1)

A triangle wave of period 1 between 0 and 1, 0 at whole numbers.

fun rand(lo: Double, hi: Double) = lo + Math.random() * (hi - lo)

A uniformly random double in [lo, hi).

fun randInt(lo: Int, hi: Int) = lo + (Math.random() * (hi - lo + 1)).toInt()

A uniformly random int in lo..hi.

data class ScorePoint(val x: Double, val y: Double)

A point on the output canvas, in pixels: what ScoreFrame.boxPoint and ScoreFrame.fromScore return.

class ScoreBox internal constructor(private val box: Box)

A box's canvas frame, as score { } sees it: read it, set it, move it, resize it.

fun get(): Rect = box.frame

fun set(x: Double = box.frame.x, y: Double = box.frame.y, w: Double = box.frame.w, h: Double = box.frame.h)

    fun set(x: Double = box.frame.x, y: Double = box.frame.y, w: Double = box.frame.w, h: Double = box.frame.h) {
        box.frame = Rect(x, y, max(1.0, w), max(1.0, h))
    }

fun set(r: Rect) = set(r.x, r.y, r.w, r.h)

fun move(dx: Double, dy: Double) = set(x = box.frame.x + dx, y = box.frame.y + dy)

fun resize(dw: Double, dh: Double) = set(w = box.frame.w + dw, h = box.frame.h + dh)

class ScoreDraw internal constructor()

The shapes the boxes under the playhead draw this frame, in output-canvas pixels.

Colours are CSS strings ("rgba(255, 238, 205, 0.96)", "hsla(28, 90%, 68%, 0.84)", "#ffb86c"). A line uses stroke, else color, else a soft white; a filled shape uses fill, else color, and is outlined only when stroke is given. ring is an outlined circle.

class ScoreDraw internal constructor() {
    private val calls = StringBuilder()
    private var count = 0

val size: Int get() = count

Shapes drawn so far this frame.

    private fun begin(kind: String) {
        if (count++ > 0) calls.append(',')
        calls.append("[\"").append(kind).append('"')
    }

    private fun n(v: Double) {
        calls.append(',')
        if (!v.isFinite()) calls.append('0')
        else calls.append(Math.round(v * 10) / 10.0)
    }

    private fun s(v: String?) {
        calls.append(',')
        if (v == null) calls.append("null")
        else calls.append('"').append(v.replace("\\", "\\\\").replace("\"", "\\\"")).append('"')
    }

    private fun style(fill: String?, stroke: String?, width: Double, alpha: Double) {
        s(fill); s(stroke); n(width); n(alpha); calls.append(']')
    }

fun line(x0: Double, y0: Double, x1: Double, y1: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun line(x0: Double, y0: Double, x1: Double, y1: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) {
        begin("l"); n(x0); n(y0); n(x1); n(y1)
        style(null, stroke ?: color ?: "rgba(255,255,255,0.88)", width, alpha)
    }

fun line(a: ScorePoint, b: ScorePoint, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun line(a: ScorePoint, b: ScorePoint, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) =
        line(a.x, a.y, b.x, b.y, stroke, color, width, alpha)

fun circle(x: Double, y: Double, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun circle(x: Double, y: Double, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) {
        begin("c"); n(x); n(y); n(max(0.0, r))
        style(fill ?: color, stroke, width, alpha)
    }

fun circle(p: ScorePoint, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun circle(p: ScorePoint, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) =
        circle(p.x, p.y, r, fill, stroke, color, width, alpha)

fun ring(x: Double, y: Double, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun ring(x: Double, y: Double, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) {
        begin("c"); n(x); n(y); n(max(0.0, r))
        style(null, stroke ?: color ?: "rgba(255,255,255,0.88)", width, alpha)
    }

fun ring(p: ScorePoint, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)

    fun ring(p: ScorePoint, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) =
        ring(p.x, p.y, r, stroke, color, width, alpha)

fun rect(x: Double, y: Double, w: Double, h: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1

    fun rect(x: Double, y: Double, w: Double, h: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0) {
        begin("r"); n(x); n(y); n(w); n(h)
        style(fill ?: color, stroke, width, alpha)
    }

fun polyline(points: List<ScorePoint>, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0, closed: Bo

    fun polyline(points: List<ScorePoint>, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0, closed: Boolean = false) {
        begin("p")
        calls.append(",[")
        points.forEachIndexed { i, p ->
            if (i > 0) calls.append(',')
            calls.append(Math.round(p.x * 10) / 10.0).append(',').append(Math.round(p.y * 10) / 10.0)
        }
        calls.append("],").append(closed)
        style(fill ?: color, stroke, width, alpha)
    }

internal fun toJson(width: Int, height: Int, t: Double, loop: Double)

    internal fun toJson(width: Int, height: Int, t: Double, loop: Double) =
        "{\"w\":$width,\"h\":$height,\"t\":${Math.round(t * 100) / 100.0},\"loop\":$loop,\"calls\":[$calls]}"
}