Skip to main content

A Morse decoder that runs in a browser tab

Β· 7 min read
UR3PKI
Software Engineer

Audio from the sound card turns into text, and all of it happens in the tab: nothing is uploaded to a server, nothing calls out to somebody else's API. A 14 MB neural network sits next to the page, the ONNX runtime is built into the same bundle, and after the first load the page works with no network at all.

The interesting part is not that it works. The interesting part is how many ways there are to get it wrong, none of which announce themselves with an error.

The signal path​

sound card (48 kHz)
β†’ AudioWorklet β†’ ring buffer
β†’ resample to 3200 Hz with a filter
β†’ FFT 256, periodic Hann window, reflect padding
β†’ spectrogram, 65 bins over 400–1200 Hz, log1p
β†’ squelch: is there a tone or not
β†’ ONNX: 8 seconds of audio β†’ per-frame distributions
β†’ greedy CTC decoding
β†’ stitching onto the previous window

An eight-second window is taken every two seconds, so neighbouring passes share six seconds of audio. That is the main difficulty, but more on it below.

The browser spoils the audio by default​

A microphone input arrives with three β€œimprovements”: echo cancellation, noise suppression and automatic gain. For a weak Morse signal each of them is fatal, and the worst is noise suppression: a steady tone is literally the thing it knows how to remove.

audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
}

The second problem from the same family: capture lives in an AudioWorklet, not on a timer. In a background tab timers are throttled to roughly one firing a minute, while a real-time audio stream is not throttled at all. On a timer, the decoder would work only while somebody was looking at it.

The preprocessing is not a matter of choice​

The network learned one particular input, and every constant here is taken from its metadata rather than chosen: a periodic Hann window (hanning(N+1)[:-1], not hanning(N)), reflect padding of 128 samples, bins 32…96 at 12.5 Hz per bin, log1p.

A spectrogram that differs from the trained one by a window or half a bin does not degrade gracefully β€” it produces confident nonsense. The most treacherous part is the padding: numpy's reflect does not repeat the edge sample, so [1,2,3] becomes [3,2,1,2,3,2,1]. Getting that wrong shifts every frame by half a window and costs you the first letter of every decode.

That is why the project has scripts/check-dsp.ts: it synthesises Morse with known text, runs it through the real sample rate, the real resampler and the real spectrogram, and then asks the model what it heard. Unit tests of the transform alone are not enough for this.

Resampling that must not be done naively​

The model author's example changes the rate by linear interpolation alone β€” and says so plainly: it is an example with minimal dependencies, and the documented way to prepare audio there is ffmpeg, which filters first.

Copying the interpolation alone would fold everything above 1600 Hz back into the band the model looks at, and a card delivering 48 kHz has plenty up there. So the filter ffmpeg would have applied is applied here: a 255-tap windowed sinc, Blackman window, cutoff 1400 Hz β€” below the Nyquist of the target rate and above the 1200 Hz the network reads.

The kernel is evaluated only at the positions actually needed, so the cost scales with the output rate rather than the input: about 1.6 million multiply-accumulates per second of audio.

Why CTC, and why greedy decoding​

The model is trained with CTC, so the output is one distribution per 15 ms frame over 41 symbols plus a blank. The best path is read greedily: take the most likely class in each frame, drop the blanks, collapse runs of identical symbols.

This is not the best decoder available β€” a beam search with a language model would read better. But a Morse decoder that quietly invents plausible words is worse than one that prints what it heard. Predictable behaviour is worth more here than a few percent of accuracy.

The most expensive mistake: stitching the windows​

Neighbouring windows share six seconds of audio, so they share text as well. But not the same text: the shared seconds get decoded afresh and come back a letter or two different. So comparing for equality does not work β€” one discrepancy inside the overlap and the whole window is appended a second time.

That is exactly what turned the first live decode into EN35UKR PSE K, repeated fifteen times over.

The working solution is to find the overlap by agreement rather than equality: the longest tail of what has already been printed that matches the head of the new window on at least 75 % of its characters.

And the other half of the same problem: the search width has to be bounded by time, not by a round number of characters. An operator calling CQ repeats the same text every fifteen seconds, and a search wide enough to span two calls will happily merge them into one and lose a transmission.

Silence that reads as text​

Left to itself, the model reads noise as a stream of E, I, T and S β€” the shortest patterns in the alphabet. Something has to be the most likely class in every frame, and noise looks more like a short element than a long one.

This decoder behaves that way, and so do the commercial phone apps. The only difference is whether the result is shown to the operator.

Telling one from the other takes a single number: how far the strongest spectral bin rises above the typical one, averaged over the window. A carrier is narrow and steady; noise is neither. There is no dependence on absolute level anywhere in this, which is why it works equally well from a cable off the rig and from a microphone held to a speaker.

The threshold was not chosen but measured: noise sits around 1.5, even a deeply buried signal does not drop below 3, so the line is drawn at 2.5 β€” deliberately low. Losing a real transmission is worse than printing a little rubbish.

Two stations at once​

The model has one output and cannot follow two senders: given both, it produces text belonging to neither.

But its entire view of the world is the spectrogram. So zeroing the bins away from the wanted tone is exactly the same as filtering the audio, and it costs one pass over an array.

With two synthesised stations 350 Hz apart, together they decoded as C?Y06IXA3PKI K; with everything outside Β±100 Hz zeroed, as CQ CQ DE UR3PKI K and TEST DE DL6LD K, both word for word.

Shifting the analysis window instead does not work: it spans 812 Hz, and a neighbour a few hundred hertz away stays inside it.

A line not to cross​

The decoder never writes to the log by itself.

In FT8 automatic logging is justified: there is a checksum, and a false decode is practically impossible. Morse has no checksum at all β€” the software cannot tell UR3PKI from UR3PKJ if the last dot drowned in a fade.

The most that is allowed is to drop a callsign into a field visibly marked as the decoder's guess. The reason is not technical but substantive: a log is a record of what happened on the air, and one invented callsign in it costs more than all the convenience of autofill.

Why this is a separate project​

The model comes from e04/deepcw-engine under AGPL-3.0. That licence extends to software provided over a network, so the source has to be available to everyone who opens the page.

That is why the decoder lives in its own repository, PetroOstapuk/cw-decoder, rather than as a page inside the platform: the licence condition is then met in full, and the platform's own code remains separate work.


To see how the alphabet itself is built, look at the code tree; to work out how long a dot lasts and how much bandwidth the signal takes, use the timing calculator.