Morse code is a stream problem wearing a history costume. 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. Go is an unusually good fit for this shape of problem: goroutines map naturally onto pipeline stages, channels carry timed events between them, and the whole decoder stays small enough to read in one sitting.
In this article we will build a streaming Morse decoder in Go. Text goes in as timed on/off events, decoded characters come out the other end, and every stage runs concurrently. No libraries, no frameworks — just the standard library and the timing rules that have not changed since 1865.
The timing rules, one more time
Everything in Morse decoding hangs off five rules from the ITU standard:
- A dit (dot) lasts one time unit.
- A dah (dash) lasts three time units.
- The gap between signals inside one letter is one unit.
- The gap between letters is three units.
- The gap between words is seven units.
At N words per minute, one unit is 1200 / N milliseconds — the PARIS standard word is 50 units long. At 20 WPM, a unit is 60 ms. Our decoder will not hard-code a speed, though. It will measure the signal it actually receives and adapt, because real senders speed up, slow down, and hand the key to someone else mid-message.
The pipeline: three stages, three goroutines
The design is a pipeline with three stages, each a goroutine, connected by channels:
- Classifier — receives raw on/off events with timestamps and emits symbols (
.or-) plus gap markers. - Assembler — collects symbols into characters and characters into words.
- Sink — prints decoded text, or hands it to whatever comes next.
Channels make the boundaries honest. The classifier never knows what the assembler does with its output, and the assembler never sees a raw timestamp. Each stage can be tested, replaced, or sped up on its own.
First, the imports and event types:
package main import ( "bufio" "fmt" "math/rand" "os" "testing" "time" ) // An Event is one edge of the signal: the key went down or up. type Event struct { On bool // true = signal started, false = signal ended At time.Time // when it happened } // A Token is what the classifier emits: a symbol, or a boundary. type Token struct { Kind byte // '.', '-', ' ' (letter gap), '/' (word gap) } Stage 1: the classifier
The classifier watches the stream of events and decides what each mark and gap means. The key decision is the threshold: a mark shorter than two units is a dit, longer is a dah. But the unit length is not fixed — it is a running estimate that the classifier updates from the marks it sees.
The estimation trick is simple and robust: dits are the most common element in English Morse (the letter E is a single dit and the most frequent letter), so the shortest marks in a rolling window are almost always dits. Keep an exponential moving average of short marks and derive the unit from it:
type Classifier struct { in <-chan Event out chan<- Token unit time.Duration // current estimate of one dit, ms markOn time.Time // when the current mark started gapOn time.Time // when the current gap started active bool // a mark is in progress } func NewClassifier(in <-chan Event, out chan<- Token) *Classifier { return &Classifier{in: in, out: out, unit: 60 * time.Millisecond} } func (c *Classifier) Run() { defer close(c.out) for ev := range c.in { if ev.On { c.closeGap(ev.At) // a mark just started: classify the gap before it c.markOn = ev.At c.active = true } else if c.active { c.closeMark(ev.At) // a mark just ended: classify it c.gapOn = ev.At c.active = false } } // End of stream: flush any pending gap. if !c.active { c.closeGap(time.Now()) } } func (c *Classifier) closeMark(end time.Time) { dur := end.Sub(c.markOn) // Fold short marks into the speed estimate. if dur < 5*c.unit/2 { c.unit = (4*c.unit + dur) / 5 } if dur < 2*c.unit { c.out <- Token{Kind: '.'} } else { c.out <- Token{Kind: '-'} } } func (c *Classifier) closeGap(now time.Time) { if c.gapOn.IsZero() { return // no gap has been measured yet } gap := now.Sub(c.gapOn) switch { case gap >= 5*c.unit: c.out <- Token{Kind: '/'} // word boundary case gap >= 2*c.unit: c.out <- Token{Kind: ' '} // letter boundary default: // Intra-character gap: nothing to emit. } } Two details worth noting. First, the 80/20 weighting in the estimate ((4*unit + dur) / 5) tracks a sender who speeds up over a conversation without jumping at a single sloppy dit. Second, the classifier never uses a timer of its own: gaps are classified when the next mark arrives, which means it works identically on live keying and on a recorded event log.
There is one gap the event stream cannot close by itself: the silence at the very end of a message. The final character never gets its letter boundary because no next mark arrives. In a real application, the event source sends a synthetic "flush" event — or a timeout — when the sender has been quiet for a word gap. Our main will handle that by closing the input channel after a quiet period, and the deferred closeGap in Run flushes the last boundary.
Stage 2: the assembler
The assembler is the simplest stage and the easiest to get wrong. It holds a buffer of symbols, looks each completed character up in the Morse table, and writes the result downstream:
var morseTable = map[string]rune{ ".-": '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', "-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4', ".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9', } func Assemble(in <-chan Token, out chan<- rune) { defer close(out) var buf string flush := func() { if ch, ok := morseTable[buf]; ok { out <- ch } else if buf != "" { out <- '�' // unknown sequence: visible, not fatal } buf = "" } for tok := range in { switch tok.Kind { case '.', '-': buf += string(tok.Kind) case ' ': flush() case '/': flush() out <- ' ' } } flush() // end of stream: emit the last character } The '�' on an unknown sequence is a deliberate choice. A practice tool should not crash because a beginner fumbled a character, but the error should be visible in the transcript so the sender can see exactly where the timing went wrong.
Stage 3: the sink, and wiring it together
The sink is whatever consumes decoded runes. For a command-line tool it prints them; for a server it might broadcast them to WebSocket clients. Here it prints, flushing stdout so the text appears as it is decoded — the whole point of a streaming design is that you do not wait for the message to finish:
func Sink(in <-chan rune, done chan<- struct{}) { for ch := range in { fmt.Printf("%c", ch) } close(done) } Wiring the pipeline is five lines, and the shape of it is the whole lesson:
func main() { events := make(chan Event) tokens := make(chan Token) chars := make(chan rune) done := make(chan struct{}) classifier := NewClassifier(events, tokens) go classifier.Run() go Assemble(tokens, chars) go Sink(chars, done) // Feed events from stdin: lines like "on 0" / "off 83" // (timestamps in milliseconds). A real source would be a // serial key, a microphone threshold, or a WebSocket feed. go feedFromStdin(events) <-done } Each go statement is a stage starting up. The channels between them are the only coupling. If the classifier is slow, the channel buffers the pressure; if the sink is slow, the assembler blocks on send, which back-pressures the classifier, which back-pressures the event source. That is flow control you get for free from the language, and it is exactly the behaviour you want when the input is a human hand on a key.
Feeding it real input
The feedFromStdin helper is deliberately boring — it exists so the decoder can be tested without hardware:
func feedFromStdin(out chan<- Event) { defer close(out) scanner := bufio.NewScanner(os.Stdin) start := time.Now() for scanner.Scan() { var state string var ms int if _, err := fmt.Sscanf(scanner.Text(), "%s %d", &state, &ms); err != nil { continue } at := start.Add(time.Duration(ms) * time.Millisecond) out <- Event{On: state == "on", At: at} } } But the interesting sources are the ones that produce events the same way. A serial-connected straight key reports key-down and key-up. A microphone pipeline thresholds audio energy and emits on/off edges. A browser keyer sends them over a WebSocket. All three plug into the same events channel without touching the classifier, because the classifier only ever sees timestamps.
Testing a stream decoder
The strongest test for any decoder is the round-trip property: encode a random string into timed events, decode them, and demand the original string back. Fuzz the timings by ±20% to simulate human unevenness:
func TestRoundTrip(t *testing.T) { rng := rand.New(rand.NewSource(42)) for i := 0; i < 200; i++ { text := randomWords(rng, 5) events := encodeEvents(text, 60*time.Millisecond, rng) got := decodeAll(events) if got != text { t.Fatalf("round-trip failed: sent %q, got %q", text, got) } } } The test drives the real pipeline — events in, runes out — so it exercises the channels, the goroutines, and the classification thresholds together. (encodeEvents is the mirror image of the decoder — marks of 1 or 3 units, gaps of 1, 3 or 7 — and decodeAll just wires the three stages together and collects the runes.) If it fails, the bug is in one stage, and the channel boundaries tell you which one: wrong symbols means the classifier, right symbols in the wrong order means the assembler, missing output means the sink.
Run it with -race. A pipeline with three goroutines and two channels is exactly the kind of code where a data race hides until production, and Go's race detector is free.
Why this shape matters beyond Morse
Three ideas from this project transfer directly to production Go work.
First, channels are contracts. The classifier's output channel is a promise about what the assembler will receive: symbols and boundaries, nothing else. When the contract is a type, the stages stay decoupled, and each one can be unit-tested by feeding its input channel and reading its output channel.
Second, back-pressure beats buffering. An unbounded queue hides overload until it explodes. An unbuffered (or small-buffer) channel pushes the pressure back to the source, which is the only place that can actually slow down. Morse senders are humans; they slow down naturally when the decoder lags. Your services should do the same.
Third, adapt, don't configure. The decoder never asks the user for a WPM setting because it measures the sender instead. Every hard-coded threshold in a system is a guess about the future; a running estimate is a measurement of the present. Prefer measurements.
Try it yourself
The whole decoder is under 200 lines. Feed it a hand-written event log — on 0, off 80, on 160, off 240 — and watch the letters appear. Then encode your own name and check the result against a free Morse code translator to confirm the timing is right before you wire up a real key. Hearing your name decoded by a pipeline you built, one goroutine at a time, is a small, satisfying proof that concurrency is not just for servers — sometimes it is for 180-year-old codes, too.
Morse Zone (morsezone.com) builds free Morse code tools and learning guides — a handy reference for checking your decoder's output against known-good text.
