Skip to content

PatClock.kt

src/jvmMain/kotlin/afield/audio/synth/PatClock.kt · 223 lines

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

The transport everything scheduled in afield.audio.synth reads: one monotonic number that can be paused, sought and subdivided, so that patterns, Boolean networks and audio launches share a single notion of the current time.

A PatClock reports that number as now, in the clock's own units. The shared clock advances one unit per second of wall time, so a unit is a second until you say otherwise. There is no tempo field: a faster grid comes from subdivide, which returns a child reading exactly factor times its parent and following the parent's pauses and seeks.

The constructor is internal, so a box reaches a clock two ways. musicClock() returns the one process-wide clock, built on first call. Box.patClock is an inherited property, and the metaroot is given the shared clock when it is created, so a box reads whatever its nearest ancestor holds. Assigning patClock writes locally, which puts that box and everything under it on a different timeline and leaves the rest of the document on the old one.

Every scheduling entry point in this package spells its clock default the same way, the box's inherited patClock and then the shared clock. The two respond families accept null there instead, and their callback scope then measures time as seconds since the launch, which nothing can pause or seek.

Each method synchronises on one lock and returns the clock, so a setup line reads as one expression:

val clock = musicClock().reset(started = true)
val sixteenths = clock.subdivide(4.0, label = "16ths")
package afield.audio.synth

import afield.document.graph.Box
import afield.document.properties.Inheritance

class PatClock internal constructor

A pausable, seekable clock: now is the reading the clock was last set to plus the time that has passed since, scaled by rate and accumulated only while the clock is running.

A clock with no parent reads the system nanosecond timer. A child made by subdivide reads its parent's now instead, so the whole tree stops when the root does.

Every accessor takes the same lock, so a clock can be read from an audio thread, a coroutine and the run loop at once. The mutators all return this.

@param parent the clock this one measures against, or null to measure against wall time @param rate units of this clock per unit of its source, which must be finite and positive @param label a name for toString, and nothing else

class PatClock internal constructor(
    private val parent: PatClock? = null,
    val rate: Double = 1.0,
    val label: String? = null
) {

init

    init {
        require(rate.isFinite() && rate > 0.0) { "PatClock.rate must be finite and > 0" }
    }

companion object

    companion object {
        @Volatile
        private var shared: PatClock? = null

fun shared(started: Boolean = true): PatClock

The one process-wide clock, created on first call with rate 1.0 and the label "global".

With started left true this also calls start on it, which starts a clock that is stopped and leaves the reading of one that is already running alone. Pass false to read the shared clock without starting it either.

        fun shared(started: Boolean = true): PatClock {
            val existing = shared
            if (existing != null) {
                if (started) existing.start()
                return existing
            }
            return synchronized(this) {
                shared?.also {
                    if (started) it.start()
                } ?: PatClock(label = "global").also {
                    shared = it
                    if (started) it.start()
                }
            }
        }
    }

    private val lock = Any()
    private var accumulated = 0.0
    private var sourceAnchor = 0.0
    private var running = false

val isRunning: Boolean

Whether the clock is currently accumulating time. A paused clock still reports its now.

    val isRunning: Boolean
        get() = synchronized(lock) { running }

val now: Double

The clock's current reading, in the clock's own units.

This is what every scheduler in the package compares an event time against, and it is the unit every is measured in throughout BeatLaunch.kt.

    val now: Double
        get() = synchronized(lock) { nowLocked() }

fun start(at: Number? = null): PatClock

Start accumulating time, optionally setting the reading to at first.

The reading never moves on its own here. Resuming a paused clock continues from where it stopped, and calling this on a clock that is already running keeps the reading it has: the time since the last anchor is folded in before the clock is re-anchored against its source. A bare start() is therefore safe to call on a clock someone else is already scheduling against, which is what makes a bare musicClock() safe.

Pass at to set the reading, or use seek and reset, when you do want it to jump.

    fun start(at: Number? = null): PatClock = apply {
        synchronized(lock) {
            // nowLocked() is the reading in all three cases: `accumulated` for a clock that is
            // paused or has never run, and the accrued reading for one that is running. Folding it
            // in before re-anchoring is what keeps start() from discarding the elapsed time.
            accumulated = at?.toDouble() ?: nowLocked()
            sourceAnchor = sourceNow()
            running = true
        }
    }

fun pause(): PatClock

Stop accumulating time, keeping the current reading. start resumes from there.

    fun pause(): PatClock = apply {
        synchronized(lock) {
            accumulated = nowLocked()
            running = false
        }
    }

fun toggle(): PatClock

pause when running, start when not. The two calls are not atomic with each other.

    fun toggle(): PatClock = apply {
        if (isRunning) pause() else start()
    }

fun seek(time: Number): PatClock

Jump the reading to time, leaving the running state alone.

Seeking a running clock keeps it running from the new reading. A waiter's target is a fixed number rather than a recomputed one, so seeking past a pending target releases it on the next poll, and seeking back behind one makes it wait until the clock arrives there again.

    fun seek(time: Number): PatClock = apply {
        synchronized(lock) {
            accumulated = time.toDouble()
            sourceAnchor = sourceNow()
        }
    }

fun reset(started: Boolean = false): PatClock

Set the reading to zero and the running state to started.

Note the default: a bare reset() leaves the clock stopped, where a bare musicClock() leaves it running. reset(started = true) is the usual opening line of a live fence.

    fun reset(started: Boolean = false): PatClock = apply {
        synchronized(lock) {
            accumulated = 0.0
            sourceAnchor = sourceNow()
            running = started
        }
    }

fun subdivide(factor: Number, label: String? = null, started: Boolean = isRunning): PatClock

A child clock running factor times as fast as this one, seeded so that it reads this.now * factor.

The child measures against this clock rather than against wall time, and it holds that product for as long as it is left running, whatever the parent does: pausing or seeking the parent moves the child with it, and the two cannot drift apart. Pausing the child is what breaks the relationship, by the length of the pause.

This is how the package spells a tempo. musicClock().subdivide(2.0) is a grid of half-second beats, on which every = 1 means what every = 0.5 means on its parent.

started defaults to the parent's running state at the moment of the call.

    fun subdivide(factor: Number, label: String? = null, started: Boolean = isRunning): PatClock {
        val scale = factor.toDouble()
        require(scale.isFinite() && scale > 0.0) { "PatClock subdivision factor must be finite and > 0" }
        val seed = now * scale
        val child = PatClock(parent = this, rate = scale, label = label)
        child.seek(seed)
        if (started) child.start(seed)
        return child
    }

override fun toString(): String

    override fun toString(): String =
        "PatClock(label=${label ?: "clock"}, now=${"%.4f".format(now)}, rate=$rate, running=$isRunning)"

    private fun sourceNow(): Double = parent?.now ?: System.nanoTime().toDouble() / 1_000_000_000.0

    private fun nowLocked(): Double {
        if (!running) return accumulated
        val elapsed = sourceNow() - sourceAnchor
        return accumulated + elapsed * rate
    }
}

var Box.patClock by Inheritance<PatClock>()

The clock this box schedules against, inherited from the nearest ancestor that has one.

The metaroot is given the shared clock when it is created, so this resolves for any box in a document without anyone setting it. Assignment writes locally, so putting a sub-tree on its own timeline is one line in the parent box:

patClock = musicClock().subdivide(3.0, label = "triplets")

fun musicClock(started: Boolean = true): PatClock = PatClock.shared(started)

The shared clock, started unless you say otherwise.

Every scheduling entry point in the package falls back to this when there is no box context to read patClock from. It is also what the metaroot's patClock is set to, so a box that has never been told about clocks is already on it.