Skip to content

TimecodeClock.kt

src/jvmMain/kotlin/afield/io/timecode/TimecodeClock.kt · 136 lines

package afield.io.timecode

import afield.audio.synth.MidiInput

TimecodeClock gives a box a timecode it can read at any moment, either generated or following incoming LTC audio or MIDI Time Code.

class TimecodeClock(val rate: TimecodeRate, private val nanoTime: () -> Long

A timecode clock a box can read: now answers "what timecode is it?" at any moment, from any thread.

It either generates time (generate: free-running from a start point) or follows a source and freewheels between updates on the wall clock: decoded LTC (followLtc, fed audio with feedLtc) or MIDI Time Code (followMtc). Following anchors the clock on each received frame and extrapolates at the measured speed, so reading it at 60 Hz from a draw loop is smooth even though LTC arrives at 24–30 frames a second and MTC in quarter frames.

val clock = TimecodeClock(TimecodeRate.FPS_25).generate(Timecode.parse("10:00:00:00", TimecodeRate.FPS_25))
println(clock.now())          // 10:00:00:00, then counting

The LTC decoder is not thread-safe: call feedLtc only from the one thread that owns the audio buffers. now, seconds and locked are safe anywhere.

class TimecodeClock(val rate: TimecodeRate, private val nanoTime: () -> Long = System::nanoTime) {

    private class Anchor(val seconds: Double, val atNanos: Long, val speed: Double)

    @Volatile private var anchor: Anchor? = null
    @Volatile private var generating = false

var freewheelSeconds: Double = 0.5

How long, in seconds, a followed clock keeps freewheeling after its last frame before locked goes false and it stops.

var source: String

Where the clock's time came from most recently: "generate", "ltc", "mtc" or "none".

    @Volatile
    var source: String = "none"
        private set

val speed: Double get() = anchor?.speed ?: 0.0

Playback speed of the source as last measured (1.0 = real time, negative = reverse).

fun generate(start: Timecode = Timecode(0, rate)): TimecodeClock

Start generating time from start at real speed.

    fun generate(start: Timecode = Timecode(0, rate)): TimecodeClock {
        anchor = Anchor(start.totalSeconds, nanoTime(), 1.0)
        generating = true
        source = "generate"
        return this
    }

fun locate(tc: Timecode, speed: Double = this.speed.takeIf { it != 0.0 } ?: 1.0)

Jump to tc keeping the current mode (a locate).

    fun locate(tc: Timecode, speed: Double = this.speed.takeIf { it != 0.0 } ?: 1.0) {
        anchor = Anchor(tc.totalSeconds, nanoTime(), speed)
    }

val locked: Boolean

True while generating, or while a followed source has delivered a frame within freewheelSeconds.

    val locked: Boolean
        get() {
            val a = anchor ?: return false
            return generating || (nanoTime() - a.atNanos) / 1e9 <= freewheelSeconds
        }

val seconds: Double

Current time in seconds since 00:00:00:00 — the number an animation or keyframe timeline wants.

    val seconds: Double
        get() {
            val a = anchor ?: return 0.0
            var elapsed = (nanoTime() - a.atNanos) / 1e9
            if (!generating) elapsed = elapsed.coerceAtMost(freewheelSeconds)
            return a.seconds + elapsed * a.speed
        }

fun now(): Timecode

The current timecode (the frame containing seconds).

    fun now(): Timecode = Timecode(
        Math.floorMod(kotlin.math.floor(seconds * rate.fps + 1e-6).toLong(), rate.framesPerDay.toLong()).toInt(), rate
    )

LTC

    private var ltc: LtcDecoder? = null
    private var ltcSamplesFed = 0L
    private var ltcSampleRate = 48000

var lastLtcFrame: LtcDecodedFrame?

The last LTC frame decoded, for display.

    @Volatile
    var lastLtcFrame: LtcDecodedFrame? = null
        private set

fun followLtc(sampleRate: Int = 48000): TimecodeClock

Follow LTC audio delivered to feedLtc at sampleRate.

    fun followLtc(sampleRate: Int = 48000): TimecodeClock {
        ltc = LtcDecoder(sampleRate, rate)
        ltcSampleRate = sampleRate
        ltcSamplesFed = 0
        generating = false
        source = "ltc"
        return this
    }

fun feedLtc(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset): List<LtcDecodedFrame>

Feed a block of LTC audio, as it arrives. A decoded frame anchors the clock at the moment its last bit ended — which, playing forward, is the start of the next frame.

    fun feedLtc(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset): List<LtcDecodedFrame> {
        val decoder = ltc ?: followLtc(ltcSampleRate).ltc!!
        val now = nanoTime()
        val frames = decoder.feed(samples, offset, length)
        ltcSamplesFed += length
        frames.lastOrNull()?.let { f ->
            lastLtcFrame = f
            val behindNanos = ((ltcSamplesFed - f.sampleEnd) / ltcSampleRate * 1e9).toLong()
            val endSeconds = if (f.reverse) f.timecode.totalSeconds else (f.timecode.frames + 1) / rate.fps
            val sp = if (f.reverse) -f.speed else f.speed
            anchor = Anchor(endSeconds, now - behindNanos, sp)
            source = "ltc"
        }
        return frames
    }

MTC

fun followMtc(decoder: MtcDecoder): TimecodeClock

Follow MTC from decoder (which you feed, or which MidiInput.mtcDecoder feeds).

    fun followMtc(decoder: MtcDecoder): TimecodeClock {
        generating = false
        source = "mtc"
        decoder.onTimecode { tc -> anchor = Anchor(tc.totalSeconds, nanoTime(), if (decoder.reverse) -1.0 else 1.0) }
        return this
    }

fun followMtc(input: MidiInput): TimecodeClock = followMtc(input.mtcDecoder())

Follow MTC arriving on input.

override fun toString(): String

    override fun toString(): String = "TimecodeClock(${now()} @ $rate, source=$source, locked=$locked)"
}