Ltc.kt
src/jvmMain/kotlin/afield/io/timecode/Ltc.kt · 340 lines
package afield.io.timecode
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.roundToLong
Linear timecode as audio: LtcEncoder renders SMPTE LTC frames to samples, LtcDecoder reads
them back from a live or recorded signal, and TimecodeWav writes and reads the mono .wav
files LTC usually travels in.
LtcFrame is the 80-bit frame itself; LtcDecodedFrame is what the decoder reports for each
frame it finds. The decoder tolerates gain, DC offset, noise, speed changes and reverse play,
which TimecodeTest checks against encoder output.
data class LtcFrame
One LTC (linear timecode) frame: the 80 bits SMPTE 12M records on an audio track once per video
frame. Bits 0..63 carry the timecode in BCD interleaved with 32 user bits; bits 64..79 are the
fixed sync word 0011 1111 1111 1101, which tells a reader where a frame ends and which way the
tape is moving.
userBits holds the eight 4-bit user-bit groups, group 1 in the lowest nibble.
data class LtcFrame(
val timecode: Timecode,
val userBits: Int = 0,
val colorFrame: Boolean = false,
) {
fun toBits(): BooleanArray
The 80 bits in transmission order (bit 0 first).
fun toBits(): BooleanArray {
val bits = BooleanArray(80)
fun put(start: Int, width: Int, value: Int) {
for (i in 0 until width) bits[start + i] = (value shr i) and 1 == 1
}
val tc = timecode
put(0, 4, tc.frame % 10)
put(8, 2, tc.frame / 10)
bits[10] = tc.rate.dropFrame
bits[11] = colorFrame
put(16, 4, tc.seconds % 10)
put(24, 3, tc.seconds / 10)
put(32, 4, tc.minutes % 10)
put(40, 3, tc.minutes / 10)
put(48, 4, tc.hours % 10)
put(56, 2, tc.hours / 10)
for (g in 0 until 8) put(USER_BIT_STARTS[g], 4, (userBits ushr (4 * g)) and 0xF)
for (i in 0 until 16) bits[64 + i] = SYNC[i]
// Polarity correction: make the frame's count of ones even, so every frame starts at the
// same signal level. It lives in bit 27 except at 25 fps, where it is bit 59.
val polarityBit = if (tc.rate == TimecodeRate.FPS_25) 59 else 27
if (bits.count { it } % 2 == 1) bits[polarityBit] = true
return bits
}
companion object
internal val USER_BIT_STARTS = intArrayOf(4, 12, 20, 28, 36, 44, 52, 60)
val SYNC: BooleanArray = "0011111111111101".map { it == '1' }.toBooleanArray()
The sync word, bits 64..79 in transmission order.
fun fromBits(bits: BooleanArray, rate: TimecodeRate = TimecodeRate.FPS_30): LtcFrame?
Decode 80 bits in transmission order. Returns null if the sync word is wrong or a BCD field is out of range. LTC does not carry the frame rate except for the drop-frame flag, so a non-drop frame is read at rate (or 30 if rate is itself drop-frame).
fun fromBits(bits: BooleanArray, rate: TimecodeRate = TimecodeRate.FPS_30): LtcFrame? {
if (bits.size != 80) return null
for (i in 0 until 16) if (bits[64 + i] != SYNC[i]) return null
fun get(start: Int, width: Int): Int {
var v = 0
for (i in 0 until width) if (bits[start + i]) v = v or (1 shl i)
return v
}
val fu = get(0, 4); val su = get(16, 4); val mu = get(32, 4); val hu = get(48, 4)
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return null
val f = get(8, 2) * 10 + fu
val s = get(24, 3) * 10 + su
val m = get(40, 3) * 10 + mu
val h = get(56, 2) * 10 + hu
val drop = bits[10]
val r = when {
drop -> TimecodeRate.FPS_2997_DF
rate.dropFrame -> TimecodeRate.FPS_30
else -> rate
}
val tc = runCatching { Timecode.of(h, m, s, f, r) }.getOrNull() ?: return null
var ub = 0
for (g in 0 until 8) ub = ub or (get(USER_BIT_STARTS[g], 4) shl (4 * g))
return LtcFrame(tc, ub, bits[11])
}
}
}
class LtcEncoder
Writes LTC as biphase-mark audio: the level flips at the start of every bit, and a 1 flips
again half-way through. The output is a square wave between -amplitude and +amplitude;
bit boundaries are placed on the nearest sample of the exact (fractional) position, so a long
render does not drift even at 29.97.
class LtcEncoder(
val rate: TimecodeRate,
val sampleRate: Int = 48000,
val amplitude: Double = 0.5,
) {
val samplesPerBit: Double get() = sampleRate / (rate.fps * 80.0)
Samples per bit at this rate, fractional.
private var level = -1.0
private var halfBitsWritten = 0L
fun encode(start: Timecode, count: Int, userBits: Int = 0): FloatArray
Encode count consecutive frames starting at start.
fun encode(start: Timecode, count: Int, userBits: Int = 0): FloatArray {
require(start.rate == rate) { "start $start is not at $rate" }
val out = ArrayList<Float>((count * 80 * samplesPerBit + 16).toInt())
var tc = start
repeat(count) {
encodeFrame(LtcFrame(tc, userBits), out)
tc += 1
}
return out.toFloatArray()
}
fun encodeFrame(frame: LtcFrame, out: MutableList<Float>)
Encode one frame, appending its samples to out.
fun encodeFrame(frame: LtcFrame, out: MutableList<Float>) {
val half = samplesPerBit / 2
for (bit in frame.toBits()) {
for (h in 0..1) {
if (h == 0 || bit) level = -level
val from = (halfBitsWritten * half).roundToLong()
halfBitsWritten++
val to = (halfBitsWritten * half).roundToLong()
val v = (level * amplitude).toFloat()
for (i in from until to) out.add(v)
}
}
}
companion object
companion object {
fun render(start: Timecode, seconds: Double, sampleRate: Int = 48000, amplitude: Double = 0.5, userBits: Int = 0): FloatArray
Render seconds of LTC from start (rounded up to whole frames).
fun render(start: Timecode, seconds: Double, sampleRate: Int = 48000, amplitude: Double = 0.5, userBits: Int = 0): FloatArray {
val frames = kotlin.math.ceil(seconds * start.rate.fps).toInt()
return LtcEncoder(start.rate, sampleRate, amplitude).encode(start, frames, userBits)
}
}
}
data class LtcDecodedFrame
A frame the LtcDecoder found, with where it sat in the input.
data class LtcDecodedFrame(
val frame: LtcFrame,
/** Earliest sample of the frame in the input stream (the start of bit 0, or of bit 79 when [reverse]). */
val sampleStart: Double,
/** Sample at which the frame's last bit ended — the earliest moment the frame can be known. */
val sampleEnd: Double,
/** True if the frame was read backwards (tape or playhead running in reverse). */
val reverse: Boolean,
/** Measured playback speed relative to the nominal rate (1.0 = real time). */
val speed: Double,
) {
val timecode: Timecode get() = frame.timecode
class LtcDecoder
A streaming LTC reader. Push audio in with feed in any block size; frames come back as they complete, with absolute sample positions counted from the first sample ever fed.
It tolerates gain (it tracks the signal envelope), DC offset (a DC blocker runs first), moderate noise (hysteresis on the zero crossings), speed changes (the bit period is tracked, not assumed) and reverse play (the sync word is recognised in both directions).
LTC does not say whether it is 24, 25 or 30 fps (only whether it is drop-frame), so pass rate when you know it. With no hint the rate is inferred from the measured bit period, which is only right when the source plays at close to real time.
Feed it from the thread that owns the audio buffers; it is not thread-safe.
class LtcDecoder(
val sampleRate: Int = 48000,
val rate: TimecodeRate? = null,
) {
DC blocker and envelope.
private var x1 = 0.0
private var y1 = 0.0
private val dcR = 1.0 - 2.0 * Math.PI * 20.0 / sampleRate
private var env = 0.0
private val envDecay = Math.exp(-1.0 / (0.05 * sampleRate))
private var level = 0
private var prevY = 0.0
private var lastCross = 0.0
private var lastTransition = Double.NaN
private var position = 0L
Interval classification.
private var period = Double.NaN // full bit period, in samples
private val warmup = ArrayList<DoubleArray>()
private var pendingShortStart = Double.NaN
Bit window.
private val bits = BooleanArray(80)
private val bitStarts = DoubleArray(80)
private var bitCount = 0
private var lastEnd = Double.NaN
private val found = ArrayList<LtcDecodedFrame>()
var framesDecoded: Int
Frames decoded so far, across all calls.
var framesDecoded: Int = 0
private set
fun feed(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset): List<LtcDecodedFrame>
Feed length samples from samples at offset. Returns the frames completed during this block.
fun feed(samples: FloatArray, offset: Int = 0, length: Int = samples.size - offset): List<LtcDecodedFrame> {
found.clear()
for (i in offset until offset + length) {
val x = samples[i].toDouble()
val y = x - x1 + dcR * y1
x1 = x; y1 = y
val a = abs(y)
env = if (a > env) a else env * envDecay
if (prevY <= 0.0 && y > 0.0 || prevY >= 0.0 && y < 0.0) {
val t = if (y != prevY) prevY / (prevY - y) else 0.0
lastCross = position - 1 + t
}
prevY = y
val thr = 0.3 * env
if (env > 1e-4) {
if (level <= 0 && y > thr) { level = 1; transition(lastCross) }
else if (level >= 0 && y < -thr) { level = -1; transition(lastCross) }
}
position++
}
return found.toList()
}
fun decodeAll(samples: FloatArray): List<LtcDecodedFrame> = feed(samples)
Decode a whole buffer at once.
source — 75 lines, from line 240
private fun transition(at: Double) {
val prev = lastTransition
lastTransition = at
if (prev.isNaN()) return
val d = at - prev
if (period.isNaN()) {
warmup.add(doubleArrayOf(prev, d))
// Learn the bit period from the two interval clusters (half bits and whole bits). A
// run of zeros shows only whole bits, so wait until both have appeared — the sync word
// guarantees that within one frame.
if (warmup.size >= 32) {
val sorted = warmup.map { it[1] }.sorted()
val lo = sorted[sorted.size / 20]
val hi = sorted[sorted.size * 19 / 20]
if (hi < 1.5 * lo && warmup.size < 400) return
period = hi
val replay = warmup.toList()
warmup.clear()
for (w in replay) interval(w[0], w[1])
}
return
}
interval(prev, d)
}
private fun interval(start: Double, d: Double) {
if (d > 1.6 * period) {
// A gap (dropout, stop): lose sync and re-learn the period.
pendingShortStart = Double.NaN
bitCount = 0
if (d > 4 * period) { period = Double.NaN; warmup.clear() }
return
}
if (d < 0.75 * period) {
period = 0.98 * period + 0.02 * (2 * d)
if (pendingShortStart.isNaN()) pendingShortStart = start
else { pushBit(true, pendingShortStart, start + d); pendingShortStart = Double.NaN }
} else {
period = 0.98 * period + 0.02 * d
pendingShortStart = Double.NaN
pushBit(false, start, start + d)
}
}
private fun pushBit(bit: Boolean, start: Double, end: Double) {
if (bitCount < 80) {
bits[bitCount] = bit; bitStarts[bitCount] = start; bitCount++
} else {
System.arraycopy(bits, 1, bits, 0, 79)
System.arraycopy(bitStarts, 1, bitStarts, 0, 79)
bits[79] = bit; bitStarts[79] = start
}
if (bitCount < 80) return
val forward = (0 until 16).all { bits[64 + it] == LtcFrame.SYNC[it] }
val backward = !forward && (0 until 16).all { bits[it] == LtcFrame.SYNC[15 - it] }
if (!forward && !backward) return
// Two checks against a false sync from noise or a dropout: the polarity bit makes every
// genuine frame's count of ones even, and a genuine frame's 80 bits are evenly spaced.
if (bits.count { it } % 2 != 0) return
val span = (end - bitStarts[0]) / 80.0
if (span < 0.8 * period || span > 1.25 * period) return
val ordered = if (forward) bits.copyOf() else BooleanArray(80) { bits[79 - it] }
val fps = sampleRate / (80.0 * period)
val hint = rate ?: inferRate(fps)
val frame = LtcFrame.fromBits(ordered, hint) ?: return
val speed = fps / frame.timecode.rate.fps
found.add(LtcDecodedFrame(frame, bitStarts[0], end, backward, speed))
framesDecoded++
lastEnd = end
bitCount = 0
}
private fun inferRate(fps: Double): TimecodeRate =
listOf(TimecodeRate.FPS_24, TimecodeRate.FPS_25, TimecodeRate.FPS_30).minBy { abs(it.fps - fps) }
}
object TimecodeWav
Mono 16-bit PCM .wav writing and reading, enough for LTC files.
fun write(file: File, samples: FloatArray, sampleRate: Int = 48000)
fun write(file: File, samples: FloatArray, sampleRate: Int = 48000) {
val data = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN)
for (s in samples) data.putShort((s.coerceIn(-1f, 1f) * 32767f).toInt().toShort())
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
header.put("RIFF".toByteArray()).putInt(36 + data.capacity()).put("WAVE".toByteArray())
header.put("fmt ".toByteArray()).putInt(16).putShort(1).putShort(1).putInt(sampleRate)
.putInt(sampleRate * 2).putShort(2).putShort(16)
header.put("data".toByteArray()).putInt(data.capacity())
file.parentFile?.mkdirs()
val out = ByteArrayOutputStream()
out.write(header.array()); out.write(data.array())
file.writeBytes(out.toByteArray())
}
fun read(file: File): Pair<FloatArray, Int>
Read a mono 16-bit PCM file written by write (or any canonical 44-byte-header mono wav). Returns samples and rate.
fun read(file: File): Pair<FloatArray, Int> {
val bytes = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN)
val rate = bytes.getInt(24)
val n = bytes.getInt(40) / 2
return FloatArray(n) { bytes.getShort(44 + 2 * it) / 32767f } to rate
}
}