Decoding Morse Code with a Finite-State Machine in TypeScript

Morse code is a stream problem. Signals arrive one at a time — a short beep, a long beep, a pause — and the decoder has to turn that stream into text without ever seeing the future. Most hobby decoders handle this with nested if statements and a handful of timers, and most of them break the moment input gets noisy: a slightly long dit becomes a dah, a hesitant pause splits a letter in two, and the whole message drifts out of sync.

There is a cleaner way to think about it. Morse decoding is exactly what finite-state machines were invented for: a small set of states, a small set of events, and a transition table that says what happens next. This article builds a complete Morse decoder as an explicit state machine in TypeScript — no libraries, no timers scattered through the logic, and a design you can test exhaustively.

Why a state machine fits Morse

A finite-state machine (FSM) is a program that is always in exactly one state, moves between states only on defined events, and carries a small amount of memory with it. Morse decoding maps onto that shape perfectly:

  • The input is a sequence of two events: signal on and signal off, each stamped with a time.
  • The states are the phases of listening: waiting for a signal, measuring a mark (tone on), or measuring a space (tone off).
  • The output is produced by transitions: when a mark ends, the machine classifies it as a dit or a dah; when a space ends, it decides whether a character or a word just finished.

Because every decision lives in the transition table, there is nowhere for timing bugs to hide. If a decode is wrong, you can point at exactly one transition.

The naive approach, and why it drifts

The common first attempt buffers raw on/off durations into arrays, then post-processes them: anything under X milliseconds is a dit, anything over is a dah, gaps under Y milliseconds are intra-character, and so on. It works on clean, machine-generated input — and fails on humans, because human keying is uneven. A beginner's dah at 12 WPM can be shorter than an expert's dit at 30 WPM. Fixed thresholds assume a single speed, and real senders vary.

A state machine does not fix that by itself. What it does is isolate the classification logic in one place, so you can swap fixed thresholds for adaptive ones without rewriting the decoder. That is the design we will build toward.

The three states

Our machine needs only three states:

  1. IDLE — no signal, waiting for the first mark. Nothing is timed here.
  2. MARK — the signal is on. The machine records when the mark started and waits for it to end.
  3. SPACE — the signal is off after at least one mark. The machine records when the space started and waits for either the next mark or a long-enough silence to close the character (or word).

Two timers matter, and both are derived from the unit length — the duration of one dit at the current speed. At N words per minute, one unit is 1200 / N milliseconds (the PARIS standard). A dit is 1 unit, a dah is 3, the gap inside a character is 1 unit, between characters 3 units, and between words 7 units.

The classification thresholds sit at the midpoints: a mark shorter than 2 units is a dit, longer is a dah. A space shorter than 2 units is an intra-character gap (do nothing); between 2 and 5 units it ends a character; 5 units or more ends a word.

The decoder

Here is the whole machine. The Decoder class owns the state, the transition table is the onSignal / onSilence pair, and the only mutable memory is the current symbol buffer plus the running unit estimate.

type State = 'IDLE' | 'MARK' | 'SPACE'; const MORSE: Record<string, string> = { '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J', '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T', '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', '--..': 'Z', '.----': '1', '..---': '2', '...--': '3', '....-': '4', '.....': '5', '-....': '6', '--...': '7', '---..': '8', '----.': '9', '-----': '0', }; export class MorseDecoder { private state: State = 'IDLE'; private markStart = 0; private spaceStart = 0; private symbols = ''; // dits/dahs of the current character private unitMs = 60; // 1 unit at 20 WPM to start private output = ''; constructor(private onChar: (ch: string) => void, private onWordEnd: () => void) {} /** Call with the current timestamp when the signal turns on. */ signalOn(now: number): void { if (this.state === 'SPACE') this.closeSpace(now - this.spaceStart); if (this.state === 'IDLE') this.output = ''; this.state = 'MARK'; this.markStart = now; } /** Call with the current timestamp when the signal turns off. */ signalOff(now: number): void { if (this.state !== 'MARK') return; // stray event: ignore const dur = now - this.markStart; this.symbols += dur < 2 * this.unitMs ? '.' : '-'; this.state = 'SPACE'; this.spaceStart = now; } /** Call periodically (or on a timeout) with the current time. */ tick(now: number): void { if (this.state === 'SPACE') this.closeSpace(now - this.spaceStart); } private closeSpace(gap: number): void { if (gap < 2 * this.unitMs) return; // intra-character gap this.flushChar(); if (gap >= 5 * this.unitMs) this.onWordEnd(); this.state = 'IDLE'; } private flushChar(): void { const ch = MORSE[this.symbols]; if (ch) this.onChar(ch); this.symbols = ''; } } 

Read the transitions, not the code around them:

  • IDLE → MARK on signalOn. Nothing is classified yet.
  • MARK → SPACE on signalOff. The mark duration is classified immediately: under two units is a dit, over is a dah, and the symbol is appended to the buffer.
  • SPACE → MARK on signalOn. First the space is closed: a short gap means "still the same character", a medium gap flushes the character, a long gap flushes it and ends a word.
  • SPACE → IDLE on tick when the silence has outlasted the word threshold. The tick is the only place time passes without an event — it is how the machine notices that the sender has stopped.

Note what is absent: there is no setTimeout per element, no queue of pending durations, and no string-splitting pass. The machine consumes events as they arrive, which means it works identically on live keying and on recorded audio.

Adaptive speed: the one upgrade that matters

Fixed thresholds break on real humans, so the machine should learn the sender's speed as it goes. The trick is to track a running estimate of the dit length and derive the unit from it, rather than fixing the unit up front.

A simple, robust estimator: keep a rolling average of the shortest marks seen recently. Dits are the most common element in English Morse (the letter E, a single dit, is the most frequent letter), so the shortest marks in a rolling window are almost always dits. Update the unit estimate after every mark:

private ditEstimate = 60; // ms, seeded at 20 WPM private adapt(markMs: number): void { // Only short marks can be dits; fold them into the estimate. if (markMs < 2.5 * this.ditEstimate) { this.ditEstimate = 0.8 * this.ditEstimate + 0.2 * markMs; this.unitMs = this.ditEstimate; } } 

Call adapt(dur) inside signalOff before classifying. The 80/20 weighting makes the estimate track a sender who speeds up over a QSO without jumping at a single sloppy dit. With this one change, the same decoder handles a nervous beginner at 8 WPM and a confident operator at 25 WPM, and it recovers gracefully when a new operator takes over mid-message — the failure mode of every fixed-threshold decoder.

Wiring it to real input

The decoder does not care where its events come from. Three sources cover nearly every project:

  • A physical or on-screen key. pointerdown calls signalOn(performance.now()), pointerup calls signalOff(performance.now()), and a setInterval(tick, 25) drives the silence detection. This is a complete practice tool in under fifty lines of glue code.
  • Audio. Pipe microphone or line-in audio through an AnalyserNode, threshold the energy, and emit on/off events when the level crosses the threshold. Add a 10–20 ms debounce so noise does not flutter the state machine — the machine itself will ignore stray events, but debouncing keeps the classification clean.
  • A serial key or straight-key interface. Any device that reports key-down/key-up events maps directly onto signalOn/signalOff. This is how the same decoder drives both a browser trainer and a real radio setup.

Because the state machine is pure — no DOM, no audio, no timers inside it — you can unit-test it by feeding synthetic event sequences and asserting the output text. That is the real payoff: a decoder you can prove correct before it ever hears a real signal. You can check your decoder's output against a free online Morse code translator to confirm the text matches.

Testing it properly

Test a decoder with property-based checks, not a handful of examples. The round-trip property is the strongest one: for any random string, encoding it to timed events and decoding those events must return the original string. Generate the events from the same timing rules the decoder assumes, then fuzz the timings by ±20% to simulate human unevenness:

import { MorseDecoder } from './decoder'; function encodeEvents(text: string, unitMs: number) { // Returns [isOn, atMs] pairs; implementation left as an exercise, // but it is the mirror image of the decoder: marks of 1 or 3 // units, gaps of 1, 3 or 7. } test('round-trip survives ±20% timing jitter', () => { const text = 'THE QUICK BROWN FOX'; const events = encodeEvents(text, 60).map(([on, t]) => [on, t * (0.8 + Math.random() * 0.4)] as const); const seen: string[] = []; const d = new MorseDecoder(c => seen.push(c), () => seen.push(' ')); for (const [on, t] of events) on ? d.signalOn(t) : d.signalOff(t); d.tick(10_000); // flush the final character expect(seen.join('')).toBe(text); }); 

If the round-trip fails, the bug is in exactly one transition — the table tells you where to look. That debuggability is the point of the design.

Frequently asked questions

Why three states and not two?
Two states (on/off) force the gap logic into the mark handler, where it does not belong. The SPACE state exists so that gaps are classified exactly once, in one place, whether the next mark arrives or silence simply continues. It is the state that turns "nothing is happening" into information.

Do I need the tick?
Yes — without it, the final character of a message never flushes, because no event ever arrives to close the last space. A 25 ms interval is plenty; at 20 WPM a unit is 60 ms, so the tick fires more than twice per unit and adds no visible latency.

What about characters that are not in the table?
flushChar silently drops unknown symbol sequences. For a practice tool that is the right call — a mistyped character should not crash the session. For a logging tool, emit a placeholder like � instead so the error is visible in the transcript.

Can this decode audio from a real radio?
Yes, with the analyser-threshold glue described above. Real-world signals add noise, fading and interference, so expect to tune the energy threshold and the debounce window for your band conditions. The state machine itself needs no changes — that separation is the whole reason to build it this way.

Morse Zone (morsezone.com) is a free online Morse code translator with audio playback and learning tools — a handy reference for checking your decoder's output against known-good text.