package coffee.liz.rookery.stackmat import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt /** * One status packet from a speed-stacks timer. * [status]: ' ' running, 'S' stopped, 'I' reset, 'A' both hands down and ready, 'L'/'R' one hand, 'C' both hands. */ data class StackmatPacket(val status: Char, val ms: Long, val digits: Int) { val running: Boolean get() = status == ' ' val handsDown: Boolean get() = status == 'L' || status == 'R' || status == 'C' || status == 'A' val ready: Boolean get() = status == 'A' } /** * Decodes the timer's data port from audio samples: 1200 baud serial (start bit, 8 data bits LSB * first, stop bit), packets of status + 5 or 6 digits + checksum (64 + digit sum) + line ending. * * Sound cards are AC-coupled and may invert the signal, so levels are tracked by edges rather than * by sign, and whichever level the line rests at between packets is taken as logical 1. */ class StackmatDecoder(sampleRate: Int, private val onPacket: (StackmatPacket) -> Unit) { private val spb = sampleRate / 1200.0 private val w = max(1, (spb / 3).toInt()) private val ring = FloatArray(w) private var ringAt = 0 private var peak = 0f private var level = false private var run = 0L private var idle: Boolean? = null private val bits = ArrayList(32) private val tail = ArrayList(12) /** Size of recent signal edges, 0..~2: for a level meter. */ val signal: Float get() = peak fun feed(samples: FloatArray, count: Int = samples.size) { for (i in 0 until count) { val x = samples[i] val d = x - ring[ringAt] ring[ringAt] = x; ringAt = (ringAt + 1) % w peak = max(peak * 0.99995f, abs(d)) run++ if (peak > 0.02f) { val next = if (d > 0.4f * peak) true else if (d < -0.4f * peak) false else level if (next != level) { runEnded(level, run); level = next; run = 0 } } // the last byte of a packet has no following edge to end it: time it out if (run == (12 * spb).toLong()) runEnded(level, run, stillGoing = true) } } private fun runEnded(lvl: Boolean, samples: Long, stillGoing: Boolean = false) { val n = (samples / spb).roundToInt() if (n >= 12) { // a long rest: this is the idle level; finish any byte in flight with stop bits if (idle == lvl && bits.isNotEmpty()) { repeat(10) { bits += lvl }; drain() } idle = lvl; bits.clear() return } if (stillGoing) return val idleLevel = idle ?: return if (n == 0 || (bits.isEmpty() && lvl == idleLevel)) return repeat(n) { bits += lvl } drain() } private fun drain() { val idleLevel = idle ?: return while (bits.size >= 10) { if (bits[0] == idleLevel || bits[9] != idleLevel) { bits.clear(); idle = null; return } // framing lost: wait for the next rest var byte = 0 for (k in 0 until 8) if (bits[1 + k] == idleLevel) byte = byte or (1 shl k) repeat(10) { bits.removeAt(0) } while (bits.isNotEmpty() && bits[0] == idleLevel) bits.removeAt(0) byte(byte) } } private fun byte(b: Int) { tail += b if (tail.size > 8) tail.removeAt(0) // does the tail end in status, digits, checksum? for (digits in intArrayOf(6, 5)) { if (tail.size < digits + 2) continue val at = tail.size - digits - 2 val status = tail[at].toChar() if (status !in " SILRCA") continue val ds = tail.subList(at + 1, at + 1 + digits) if (ds.any { it !in '0'.code..'9'.code }) continue if (64 + ds.sumOf { it - '0'.code } != tail.last()) continue val v = ds.map { it - '0'.code } val ms = if (digits == 6) v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + v[3] * 100 + v[4] * 10 + v[5] else v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + (v[3] * 10 + v[4]) * 10L tail.clear() onPacket(StackmatPacket(status, ms, digits)) return } } } /** * Turns the packet stream into solves: a result is taken when the timer shows a stopped, non-zero * time after having been reset, so a packet lost at the moment of stopping doesn't lose the solve * and a time left on the display isn't recorded twice. */ class StackmatSession(private val onSolve: (ms: Long) -> Unit) { var last: StackmatPacket? = null private set private var armed = false fun packet(p: StackmatPacket) { last = p if (p.ms == 0L || p.running) armed = true else if (p.status == 'S' && armed) { armed = false; onSolve(p.ms) } } }