Morse code: one table, two ambiguities, three details
Morse itself is trivial; the trouble is in the details — who owns the space, how to normalise full-width dots, and how long one unit actually is.
Morse code is the kind of thing that reads as obvious and then turns into a pile of edge cases. There is one table, two ambiguities, and three details you have to decide before writing a line.
The table: letters are not enough
A converter with 26 letters is a toy. Digits and common punctuation each get their own block, and then there are prosigns — not letter combinations but standalone joined sequences with their own meaning, such as ...---... for distress and -...- for a new paragraph. Prosigns have no letter gaps, so they cannot be split like ordinary words during decoding.
The table lives in the pure logic module, and the reference table in the UI renders straight from it, so there is exactly one copy of the facts.
Ambiguity one: who owns the space
When encoding, letters are separated by a space and words by a slash:
encode('HI ALL').code; // '.... .. / .- .-.. .-..'
Decoding runs the other way: slash splits words, space splits letters. But pasted Morse often has spaces and no slashes, and then it is just several letters inside one word — the duration calculation counts letter gaps of three units, not word gaps of seven. That is not a bug, it is a rule that has to be documented.
Ambiguity two: what counts as a dot
Morse copied out of a web page, a document or a chat window arrives full of lookalike characters. Rather than failing on parse, normalise first:
normalizeCode('·−·−'); // '.-.-'
normalizeCode('-_-'); // '---'
Dots, full stops, bullets and middle dots all become .; hyphens, en dashes, em dashes and underscores become -; vertical bars act as letter separators. After that step the parser only has two characters to recognise.
Detail one: timing has a standard
One unit is one dot. A dash is three units, elements inside a letter are separated by one unit, letters by three, words by seven. So S is five units and ... --- ... is twenty-seven. The UI shows the total duration, converted at 20 WPM — one unit being 60 milliseconds.
I got this wrong in the first version: I summed only the element lengths and forgot the gaps, which made S come out as 3. One assertion — timingUnits('...') === 5 — makes that mistake impossible to repeat.
Detail two: playback and duration must share a source
If you want people to hear it, the pulse sequence used for playback and the duration calculation have to come from the same definition, or the display says 0.3 seconds while the audio runs 0.36. So pulses() produces { on, units } entries and timingUnits() sums them, with an assertion that the two agree — a nail driven between the two functions.
Audio is synthesised live with WebAudio: a 620 Hz sine, with a short envelope at each pulse edge to avoid clicks. If the browser refuses audio, it fails silently and the tool stays usable.
Detail three: say what could not be converted
When encoding hits a character outside the table — a CJK ideograph, say — do not drop it quietly. Return an unknown array and surface it in the UI, so the reader knows exactly which characters did not make it. Same on the decoding side: use ? as a placeholder and list the offending code groups.
Silent fallbacks are the most common and most damaging mistake in tool code.
Closing
The hard part of Morse is not the algorithm, it is the conventions: is a space a letter or a word, what counts as a dot, how long a unit is. Write those three down, nail them into the test suite, and all that is left is rendering.

Comments
…