TL;DR: Keythm is a typing test with per-key mechanical keyboard sounds, four test modes and a results screen with a WPM-over-time chart. It is built with Next.js, React, TypeScript, Tailwind CSS, the Web Audio API and Serwist, and works offline as a PWA. The hard parts were slicing ~80 key samples out of a single 1.9MB OGG sprite on every keystroke, and catching scripted typing with 12 anti-cheat checks on each result.
Q should not sound like Enter
I type on a mechanical keyboard every day. Open a typing test in the browser and the keypress that clicks on my desk makes no sound on the page, and at the end I get a WPM number with no breakdown of what happened during the test. That disconnect kept bothering me.
Before writing any UI, I set the one constraint every later technical decision follows from: per-key audio. Pressing Q has to sound different from pressing Enter, and the sound has to arrive fast enough that you never notice a delay. Nobody sees a 50ms delay on a hover state, but 50ms between a keypress and its sound feels broken.

One hook runs four modes in the browser
Keythm is a Next.js app where everything that matters runs client-side. A single hook, useTypingTest, owns the full test lifecycle across four modes:
- Timed: 15 to 120 seconds.
- Word count: 10 to 100 words.
- Quotes: curated quotes, filtered by length.
- Zen: unlimited, Shift+Enter to end.
Every second, the engine snapshots WPM, raw WPM and error count. Those snapshots feed three things: the live stats overlay while you type, the Recharts WPM-over-time chart on the results screen, and the consistency score. Consistency is 100 minus the coefficient of variation of the per-second WPMs (the standard deviation divided by the mean), so a steady rhythm scores high and erratic bursts score low.
State lives in localStorage under a tc- prefix: settings, personal bests, preferences. Serwist precaches the app, so after the first visit it works with no connection. Committing to offline early meant I stopped reaching for server state by default. The only server-side state is a visit counter in LibSQL through Drizzle, so if the server is unreachable you lose that count and nothing else.
Two smaller pieces handle first paint and the frame budget:
- Theme before hydration. A small inline script in
<head>reads the accent color from localStorage before the first paint, so the page never flashes the default color and then swaps. - Keyboard gating. The on-screen keyboard is a full QWERTY layout that animates every key press. An IntersectionObserver at a 10% threshold detaches its physical key listeners when it scrolls out of view, so it does no work while you can't see it.
One 1.9MB sprite for 80 keys
The entire sound library lives in a single 1.9MB OGG sprite: ~80 key samples packed into one file, each mapped to a [startMs, durationMs] tuple for both the "down" and the "up" phase.
The fetch starts at module import time, before any component mounts (a <link rel="preload"> in the layout starts it even earlier), and the keyboard provider decodes the result into an AudioBuffer once. When you press a key, BufferSource.start(0, offset, duration) slices the right sample out of that buffer on demand, so the whole library costs one HTTP request and one decode.
Browser restrictions took the most debugging. Modern browsers suspend new AudioContexts until the user interacts with the page (thanks, autoplay policies). Keythm listens for the first keydown or pointerdown on the document and resumes the context immediately, so the audio pipeline is warm by the time you start typing.
The second surprise was macOS, which has a habit of swallowing keyup events after Cmd chords. A modifiersDownRef tracks which modifier keys are held so a missing keyup doesn't leave the keyboard state stuck.
Not everything goes through the sprite. "Faah mode" is an Easter egg that plays a dramatic sound effect on wrong keys, and it uses a plain HTML Audio element because it doesn't need the sprite's timing precision.
Catching bots that type at 120 WPM
The results screen shows six metrics: WPM, raw WPM, accuracy, consistency, elapsed time, and a character-level breakdown (correct, incorrect, extra, missed, corrected). Score 100+ WPM and confetti fires. A score only means something if a script can't post it, so Keythm runs 12 anti-cheat checks on every result.
The first layer is sanity checks (no keystrokes, non-finite numbers, a test shorter than 2 seconds) and hard thresholds. WPM above 300, raw WPM above 350, or more than 30 characters per second catches the obvious bots.
Those thresholds miss the more interesting cheat: a script that types at a plausible 120 WPM with inhuman steadiness. Catching that took statistical checks on the per-second WPM history instead of bounds on the final number:
- Impossible bursts: any single second above 600 WPM.
- AFK gaps: more than 3 consecutive seconds with no input in the middle of a test.
- Flat history: every per-second WPM within 1 WPM of the others over 4+ seconds.
- Perfect consistency: a consistency score of 99 or higher.
Calibrating these to avoid false positives on legitimate typists was the hardest part, so the flat-history and consistency checks only activate above 80 WPM. I didn't want slow but steady typists flagged for being steady.
Trade-offs
| Chose | Over | Why | Cost |
|---|---|---|---|
| One OGG sprite with offset tuples | 80+ audio files, one per key | One fetch, one decode, one lookup table. Separate files meant 80+ requests (or a bundling step to maintain), 80+ decode calls, and cache invalidation per file. | Every offset was mapped by hand, a one-time cost paid during development. |
| localStorage plus Serwist precaching | Accounts and server-side settings | No auth, no round-trips, and the app works offline after the first visit. | Personal bests live in one browser. Nothing syncs across devices. |
| Steadiness checks gated at 80 WPM | Running them on every result | Consistent typists below 80 WPM don't get flagged as bots. | A steady script typing under 80 WPM only faces the threshold, burst and AFK checks. |
| Blocking inline script for the accent color | Applying the theme after React hydrates | Correct colors from the first frame, no flash. | A script outside React that runs before the page can paint. |
| IntersectionObserver gating on the keyboard | Keeping key listeners attached all the time | No keyboard work while it's off-screen. | The on-screen keyboard doesn't track keys while scrolled out of view. |
| Plain HTML Audio for faah mode | Adding it to the sprite | An Easter egg doesn't need sprite precision. | Two audio paths in the codebase instead of one. |
Results
What shipped:
- Per-key audio for ~80 keys from one 1.9MB file, one request and one decode per visit.
- Four test modes, six result metrics and a WPM-over-time chart.
- 12 anti-cheat checks: sanity checks, hard thresholds, and checks on the per-second WPM history.
- 6 keyboard color schemes (Classic, Mint, Royal, Dolch, Sand, Scarlet) that also set the site accent, and 9 typing fonts.
- An offline-first PWA: all state in localStorage, precached by Serwist, usable without a connection after the first visit.
What I'd change
The gap between "has sound" and "sounds right" was an order of magnitude more engineering than I expected: an eager fetch at import, a single decode, sprite slicing, autoplay workarounds, the macOS keyup fix. What I'd add is a measured keystroke-to-sound latency per browser. The target is under one frame, and I haven't measured it yet.
The anti-cheat thresholds also need data. Before trusting them on a public leaderboard, I want to check them against logged results: how often a real person trips a check, and how often a script slips under the 80 WPM gate.
A leaderboard is the next step. Drizzle and LibSQL already run the visit counter, so the database is in place. The rule stays the same: the server adds global stats, and the typing test keeps working without it.