diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
| commit | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (patch) | |
| tree | 0b8726b1d3f493cbf5adcecf10997e3c72e27762 /core/src/commonTest/kotlin/coffee | |
| parent | 6004b4cda755202491648e0d724b66d50eb0b96a (diff) | |
| download | rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.tar.gz rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.zip | |
Plain UI copy; smart cube pairing; stackmat input
- Remove marquee, snow, pointer-following, hostname line and penguin
speech; the penguin keeps to its corner. UI text is plain labels.
- Smart cube: scan / pair / connect / forget in settings, with live
battery, move count and an unfolded view of the cube's state, and
"mark solved" to resync. Paired cubes go in the synced device list.
CubeBackend abstracts the platform; Linux (BlueZ) only for now.
- Stackmat: audio decoder for the timer's 1200-baud data port (edge
based, so AC coupling and inverted inputs work; 5- and 6-digit
packets, checksum verified) and a session that records each solve
once. Settings pick the input and show signal state; when enabled
the mat drives the timer display and lamps instead of the keyboard.
Tested against synthetic audio only.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'core/src/commonTest/kotlin/coffee')
| -rw-r--r-- | core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt | 83 |
1 files changed, 83 insertions, 0 deletions
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt new file mode 100644 index 0000000..f6071ff --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt @@ -0,0 +1,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) + } +} |
