MorseWorld / Engineering case study

Reading a hand that has no rhythm

Translating text to Morse is a lookup table. The hard part is the other direction, from a human hand: a beginner's dot and dash differ by tens of milliseconds, and their pauses do not hold any ratio at all. This is what it took to read that reliably, in a browser tab, with no server behind it.

My role: sole engineer — timing core, key decoder, audio, lesson design, interface, both languages. 2026.

00 / Why

A saturated niche with nothing good in it

There are dozens of Morse translators online and almost all of them do the same three things. Competing on a feature list would be pointless. Everything that separates them sits below the feature list: whether the timing drifts at forty words per minute, whether the speaker clicks at the start of every dot, whether you can key a message by hand and see immediately how it was read.

So the interesting product question was not what to build but what to be honest about. The site states which code tables have been checked against a primary source and which have not, and the page for the microphone decoder says plainly that it is specified and not yet built. A tool that is trusted with timing has to be trustworthy about itself first.

01 / Architecture

One representation, four sources

A Core

Everything in src/core imports nothing from the browser. It takes numbers and returns numbers: durations, thresholds, groupings, scores. That is what makes the timing testable in principle and identical no matter which source fed it.

B Signal

A keyboard press, a mouse, a touch and — once it exists — a microphone all reduce to the same shape: how long the key was down, how long the silence before it was. Four inputs, one decoder.

C Audio

Playback is scheduled against AudioContext.currentTime, not timers, so a busy main thread cannot smear the rhythm. Each tone opens and closes on a short gain ramp — without it the speaker clicks on every dot, which at twenty words per minute is a click every sixty milliseconds.

D State

Lesson level and per-letter statistics live in localStorage. There is no account and no sync, because there is no backend at all — the page makes no network request once it has loaded.

02 / How the key is read

From a press to a letter

Three stages, and each one was rewritten after it failed on a real hand. The short version of each, in the code that does it.

1 — Thresholds come from the speed, not from the strokes

The first version measured the strokes and split them into short and long by their own median. It reads well in a description and fails on the first press of the session: with one stroke recorded there is no median, the boundary collapses to zero, and every dot is reported as a dash. Deriving the boundary from the speed the person chose removes the circularity — the dot length is 1200 / WPM by definition, and the boundary sits halfway between one unit and three.

src/core/keyDecoder.ts

export function unitFor(wpm: number): number {
  return 1200 / Math.min(WPM_MAX, Math.max(WPM_MIN, wpm))
}

export function thresholdsFor(wpm: number): Thresholds {
  const unit = unitFor(wpm)
  return { dashAt: unit * 2, letterAt: unit * 3, wordAt: unit * 7 }
}

2 — Time the press, not the handler

A short dot kept reading as a dash, but only ever the first one. The cause was not the decoder: the first press of a page builds the AudioContext, which blocks the main thread for tens of milliseconds. The release happens on time and is handled late, so a clock read inside the handler measures the freeze as part of the press. The event's own timestamp sits on the same scale and records when the press actually happened.

src/components/practice/useStraightKey.ts

// The event's timestamp, not the moment the handler got round to it.
const press = useCallback((at?: number) => {
  if (downAt.current) return
  downAt.current = at ?? performance.now()
  // ...
}, [])

const release = useCallback((at?: number) => {
  if (!downAt.current) return
  const now = at ?? performance.now()
  const hold = Math.max(0, now - downAt.current)
  const gap = releasedAt.current
    ? Math.max(0, downAt.current - releasedAt.current)
    : 0
  // ...
}, [])

Verified by delivering a pre-stamped release four hundred milliseconds late: still read as a dot.

3 — Composing is slower than copying, so the gaps differ

In the sending exercise the target is on screen and the person keys it. Their characters come out right and their pauses do not: recalling the next letter takes far longer than the standard gap, so at twenty words per minute a five-hundred-millisecond pause between letters crossed the word boundary and the message fell apart into single-letter words. The fix is the Farnsworth idea applied to listening rather than sending — stretch the gaps, keep the characters — with each boundary placed at the geometric midpoint between the two gaps it separates.

src/core/keyDecoder.ts

export function sendingThresholds(
  charWpm: number,
  effectiveWpm: number,
): Thresholds {
  const gaps = gapsFor(charWpm, Math.min(effectiveWpm, charWpm))
  const between = (a: number, b: number) => Math.sqrt(a * b)

  return {
    dashAt: unitFor(charWpm) * 2,
    letterAt: between(gaps.element, gaps.letter),
    wordAt: between(gaps.letter, gaps.word),
  }
}

03 / Decisions

Decisions that mattered

01 The person sets the speed

An earlier build inferred the speed from how the person was keying and adapted as it went. It was the cleverer design and it was worse: the reference moved while they were still learning to hit it, so the same hand got different readings and there was nothing stable to improve against. The speed is now a slider they own, and the decoder only ever advises — and only when the current setting would genuinely misread what was just sent.

Why it matters: a practice tool has to be a fixed target. Adaptive timing hides the very error the learner is trying to see.

02 Score by alignment, not by position

Comparing the target and the attempt index by index punishes one dropped character as if every character after it were also wrong. Scoring runs a Levenshtein alignment with backtracking instead, so a miss is reported as a miss and an extra as an extra.

Why it matters: the feedback names the actual mistake. A learner who dropped one letter is told they dropped one letter, not that the second half of the message was wrong.

03 Don't grade what was never asked

The sending exercise used to infer word boundaries from the pauses and mark them. But the target is known and printed on screen — the word breaks were never the exercise. They were removed from the comparison entirely rather than made more forgiving.

Why it matters: every inferred quantity is a place to be wrong about something nobody was being tested on.

04 One alphabet chosen by hand, the direction guessed

Latin and Cyrillic overlap heavily by code — .- is both A and А — so the table cannot be determined while decoding, and it is always an explicit switch. The direction is a different matter: Morse in the text field unambiguously means a request to decode, so the site works that out itself.

Why it matters: guessing what is ambiguous produces confident nonsense. Guessing what is not saves a click.

04 / Shipped

What is in the product

TranslatorBoth directions on every keystroke, with the alphabet on an explicit switch and the direction detected. Playback at any speed and tone.
SandboxA straight key from keyboard, mouse or touch, decoded live, with the dot, dash and gap boundaries shown in milliseconds rather than hidden.
Koch lessonsThree directions — meeting a character, sending it by hand, copying it by ear — at full character speed from the first lesson, each with its own level and per-letter statistics.
ReadingPublic-domain excerpts instead of random letters, in both directions. Every text carries its source.
Two languagesEnglish and Ukrainian throughout. The Q-codes stay as they are on air; only their expansions are translated.

Not in it yet: the decoder for a microphone and an audio file. The pipeline is specified down to the window size and the threshold hysteresis, and the page for it is marked as planned rather than dressed up as working. There are also no automated tests, which is the largest gap here — the key decoder was rewritten twice without one.

05 / Try it

Send something by hand

Open the sandbox, hold the space bar, and watch what comes back. Nothing to sign up for, and nothing leaves the tab.