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
|
package coffee.liz.rookery
import coffee.liz.rookery.stackmat.StackmatDecoder
import coffee.liz.rookery.stackmat.StackmatPacket
import coffee.liz.rookery.stackmat.StackmatSession
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StackmatTest {
private fun packetBytes(status: Char, digits: String): List<Int> =
listOf(status.code) + digits.map { it.code } + (64 + digits.sumOf { it - '0' }) + listOf('\n'.code, '\r'.code)
/** Audio for a list of packets, the way a sound card hears it: optional inversion, AC coupling, noise. */
private fun audio(packets: List<List<Int>>, rate: Int, invert: Boolean, noise: Float, rng: Random = Random(1)): FloatArray {
val bits = ArrayList<Boolean>()
repeat(40) { bits += true }
for (p in packets) {
for (b in p) { bits += false; for (k in 0 until 8) bits += (b shr k) and 1 == 1; bits += true }
repeat(30) { bits += true } // rest between packets
}
val spb = rate / 1200.0
val n = (bits.size * spb).toInt()
val out = FloatArray(n)
var hp = 0f; var prev = 0f
val alpha = 1f - 1f / (0.004f * rate) // ~4 ms high-pass
for (i in 0 until n) {
val raw = (if (bits[(i / spb).toInt()] != invert) 0.5f else -0.5f)
hp = alpha * (hp + raw - prev); prev = raw
out[i] = hp + (rng.nextFloat() - 0.5f) * noise
}
return out
}
private fun decode(samples: FloatArray, rate: Int, chunk: Int = 512): List<StackmatPacket> {
val got = mutableListOf<StackmatPacket>()
val d = StackmatDecoder(rate) { got += it }
var at = 0
while (at < samples.size) { val n = minOf(chunk, samples.size - at); d.feed(samples.copyOfRange(at, at + n)); at += n }
return got
}
private val sequence = listOf(packetBytes('I', "000000"), packetBytes('A', "000000"), packetBytes(' ', "001234"), packetBytes(' ', "012345"), packetBytes('S', "112345"), packetBytes('S', "112345"))
private val expected = listOf(
StackmatPacket('I', 0, 6), StackmatPacket('A', 0, 6), StackmatPacket(' ', 1_234, 6), StackmatPacket(' ', 12_345, 6),
StackmatPacket('S', 72_345, 6), StackmatPacket('S', 72_345, 6),
)
@Test fun decodesCleanSignalAtCommonRates() {
for (rate in listOf(44_100, 48_000, 16_000)) assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0f), rate), "at $rate")
}
@Test fun decodesInvertedNoisySignal() {
for (rate in listOf(44_100, 48_000)) {
assertEquals(expected, decode(audio(sequence, rate, invert = true, noise = 0.08f), rate), "inverted at $rate")
assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0.08f), rate, chunk = 97), "noisy at $rate")
}
}
@Test fun fiveDigitTimersAndBadChecksums() {
val gen3 = packetBytes('S', "12345") // 1:23.45
val corrupt = packetBytes('S', "112345").toMutableList().also { it[3] = '9'.code }
val got = decode(audio(listOf(gen3, corrupt, gen3), 44_100, invert = false, noise = 0f), 44_100)
assertEquals(listOf(StackmatPacket('S', 83_450, 5), StackmatPacket('S', 83_450, 5)), got)
}
@Test fun silenceAndNoiseDecodeToNothing() {
val rng = Random(4)
assertTrue(decode(FloatArray(48_000) { (rng.nextFloat() - 0.5f) * 0.01f }, 48_000).isEmpty())
assertTrue(decode(FloatArray(48_000) { rng.nextFloat() - 0.5f }, 48_000).isEmpty())
}
@Test fun sessionRecordsEachSolveOnce() {
val solves = mutableListOf<Long>()
val s = StackmatSession { solves += it }
fun p(c: Char, ms: Long) = s.packet(StackmatPacket(c, ms, 6))
p('S', 9_990) // a time already on the display when we connect: not ours
p('I', 0); p('A', 0); p(' ', 100); p(' ', 5_000); p('S', 12_340); p('S', 12_340); p('C', 12_340)
p('I', 0); p('S', 8_000) // the running packets were lost entirely
assertEquals(listOf(12_340L, 8_000L), solves)
}
}
|