Timecode.kt
src/jvmMain/kotlin/afield/io/timecode/Timecode.kt · 152 lines
package afield.io.timecode
The SMPTE timecode value types: TimecodeRate (24, 25, 29.97 drop-frame, 30) and Timecode, a frame count at a rate with HH:MM:SS:FF labelling, parsing and arithmetic.
The LTC, MTC and clock code in this package all exchange Timecode values.
enum class TimecodeRate
SMPTE timecode: the HH:MM:SS:FF clock that lighting desks, video servers and DAWs share.
A Timecode is a frame count at a TimecodeRate. Everything else (the four fields, the label,
seconds) is derived from that count, so arithmetic is plain integer arithmetic and drop-frame
labelling is handled in exactly one place (fromFrames / toFrames).
val t = Timecode.parse("00:00:59;29") // 29.97 drop-frame, because of the ';'
println(t + 1) // 00:01:00;02 — ;00 and ;01 are skipped
enum class TimecodeRate(
/** Frames counted per labelled second (24, 25 or 30). */
val nominalFps: Int,
/** True for 29.97 drop-frame: labels ;00 and ;01 are skipped at every minute except each tenth. */
val dropFrame: Boolean,
/** Real frames per second of wall time. */
val fps: Double,
/** The two-bit rate code MTC carries (0 = 24, 1 = 25, 2 = 29.97 DF, 3 = 30). */
val mtcCode: Int,
) {
FPS_24(24, false, 24.0, 0),
FPS_25(25, false, 25.0, 1),
FPS_2997_DF(30, true, 30000.0 / 1001.0, 2),
FPS_30(30, false, 30.0, 3);
val framesPerDay: Int
Number of distinct frames in 24 hours, after which timecode wraps.
val framesPerDay: Int
get() = if (dropFrame) 24 * 6 * FRAMES_PER_10_MIN_DF else 24 * 3600 * nominalFps
override fun toString(): String
override fun toString(): String = when (this) {
FPS_24 -> "24"
FPS_25 -> "25"
FPS_2997_DF -> "29.97df"
FPS_30 -> "30"
}
companion object
internal const val FRAMES_PER_10_MIN_DF = 17982
internal const val FRAMES_PER_MIN_DF = 1798
fun fromMtcCode(code: Int): TimecodeRate = entries.first { it.mtcCode == (code and 3) }
The rate with this MTC rate code.
fun parse(text: String): TimecodeRate
Parse "24", "25", "29.97", "29.97df", "30".
fun parse(text: String): TimecodeRate = when (text.trim().lowercase()) {
"24" -> FPS_24
"25" -> FPS_25
"29.97", "29.97df", "2997", "df" -> FPS_2997_DF
"30" -> FPS_30
else -> throw IllegalArgumentException("unknown timecode rate '$text' (24, 25, 29.97df, 30)")
}
}
}
data class Timecode(val frames: Int, val rate: TimecodeRate) : Comparable<Timecode>
A position on a SMPTE timecode clock. frames is the count of real frames since 00:00:00:00,
always in 0 until rate.framesPerDay.
data class Timecode(val frames: Int, val rate: TimecodeRate) : Comparable<Timecode> {
init
init {
require(frames in 0 until rate.framesPerDay) { "frame $frames outside one day at $rate" }
}
private val fields: IntArray by lazy { fieldsOf(frames, rate) }
val hours: Int get() = fields[0]
val minutes: Int get() = fields[1]
val seconds: Int get() = fields[2]
val frame: Int get() = fields[3]
val totalSeconds: Double get() = frames / rate.fps
Wall-clock seconds since 00:00:00:00 (for 29.97 this is the real, not the labelled, time).
operator fun plus(n: Int): Timecode
Add n frames, wrapping at 24 hours.
operator fun plus(n: Int): Timecode = Timecode(Math.floorMod(frames + n, rate.framesPerDay), rate)
operator fun minus(n: Int): Timecode = plus(-n)
Subtract n frames, wrapping at 24 hours.
operator fun minus(other: Timecode): Int
Frames from other to this one (no wrapping).
operator fun minus(other: Timecode): Int {
require(other.rate == rate) { "cannot subtract $other from $this: different rates" }
return frames - other.frames
}
override fun compareTo(other: Timecode): Int = frames.compareTo(other.frames)
override fun toString(): String
"HH:MM:SS:FF", or "HH:MM:SS;FF" for drop-frame.
override fun toString(): String {
val sep = if (rate.dropFrame) ';' else ':'
return "%02d:%02d:%02d%c%02d".format(hours, minutes, seconds, sep, frame)
}
companion object
companion object {
fun of(h: Int, m: Int, s: Int, f: Int, rate: TimecodeRate): Timecode
The timecode labelled h:m:s:f. Throws for labels that do not exist — including the
skipped drop-frame labels ;00 and ;01 at minutes not divisible by ten.
fun of(h: Int, m: Int, s: Int, f: Int, rate: TimecodeRate): Timecode {
require(h in 0..23 && m in 0..59 && s in 0..59 && f in 0 until rate.nominalFps) {
"invalid timecode %02d:%02d:%02d:%02d at $rate".format(h, m, s, f)
}
require(!(rate.dropFrame && s == 0 && f < 2 && m % 10 != 0)) {
"%02d:%02d:%02d;%02d does not exist in drop-frame (;00 and ;01 are skipped)".format(h, m, s, f)
}
val totalMinutes = 60 * h + m
var n = ((3600 * h + 60 * m + s) * rate.nominalFps) + f
if (rate.dropFrame) n -= 2 * (totalMinutes - totalMinutes / 10)
return Timecode(n, rate)
}
fun fromSeconds(seconds: Double, rate: TimecodeRate): Timecode
The frame nearest seconds of wall time (wrapping at 24 hours).
fun fromSeconds(seconds: Double, rate: TimecodeRate): Timecode =
Timecode(Math.floorMod(Math.round(seconds * rate.fps), rate.framesPerDay.toLong()).toInt(), rate)
fun parse(text: String, rate: TimecodeRate? = null): Timecode
Parse "HH:MM:SS:FF" or "HH:MM:SS;FF". A ';' (or '.') before the frames means 29.97 drop-frame; otherwise rate is used (default 30).
fun parse(text: String, rate: TimecodeRate? = null): Timecode {
val m = Regex("""^\s*(\d{1,2}):(\d{2}):(\d{2})([:;.,])(\d{2})\s*$""").matchEntire(text)
?: throw IllegalArgumentException("not a timecode: '$text' (want HH:MM:SS:FF or HH:MM:SS;FF)")
val (h, mi, s, sep, f) = m.destructured
val r = rate ?: if (sep == ";" || sep == "." || sep == ",") TimecodeRate.FPS_2997_DF else TimecodeRate.FPS_30
return of(h.toInt(), mi.toInt(), s.toInt(), f.toInt(), r)
}
internal fun fieldsOf(frames: Int, rate: TimecodeRate): IntArray
internal fun fieldsOf(frames: Int, rate: TimecodeRate): IntArray {
var n = frames
if (rate.dropFrame) {
val d = n / TimecodeRate.FRAMES_PER_10_MIN_DF
val r = n % TimecodeRate.FRAMES_PER_10_MIN_DF
n += 18 * d + if (r > 1) 2 * ((r - 2) / TimecodeRate.FRAMES_PER_MIN_DF) else 0
}
val fps = rate.nominalFps
return intArrayOf(n / (fps * 3600), (n / (fps * 60)) % 60, (n / fps) % 60, n % fps)
}
}
}