summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt
blob: 0507fa8ff0aa92650799689985d1dc951c002302 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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<Boolean>(32)
    private val tail = ArrayList<Int>(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) }
    }
}