When I started building an online Morse code translator, I assumed the hard part would be the alphabet. Map letters to dots and dashes, done. I was wrong. The alphabet took an afternoon. The timing took weeks.
Here's the thing nobody tells you about Morse code until you actually try to generate it: it's not really an alphabet. It's a rhythm. And rhythm is brutally unforgiving to get wrong in code.
The bug that wasn't a bug
Early on, my translator worked fine for something short like SOS. But longer sentences sounded... off. Not broken exactly, just slightly wrong in a way that was hard to describe. I compared it against real recordings of amateur radio operators sending CW, and the difference was obvious once I heard it side by side: my dashes were too short relative to my dots, and my word gaps were inconsistent.
It turned out I'd been treating "dot" and "dash" as separate arbitrary durations I could tune by ear, instead of treating them as ratios of a single base unit. That's the actual rule, standardized internationally as ITU-R M.1677-1:
- A dot is 1 unit
- A dash is exactly 3 units — not "about 3," exactly 3
- The gap between dots/dashes inside a letter is 1 unit
- The gap between letters is 3 units
- The gap between words is 7 units
Once everything is derived from a single base unit (itself calculated from target WPM using the classic "PARIS" reference word — 1 WPM = 1.2 seconds per dot-unit), the whole system snaps into place and starts sounding like an actual telegraph key instead of a beeping approximation.
Here's roughly what the core timing logic ended up looking like, stripped down to the essentials:
js
function dotUnitSeconds(wpm) {
return 1.2 / wpm; // PARIS reference word standard
}
function buildTimingSequence(morse, wpm, farnsworthWpm) {
const unit = dotUnitSeconds(wpm);
const effectiveUnit = farnsworthWpm ? dotUnitSeconds(farnsworthWpm) : unit;
const events = [];
morse.split(' / ').forEach((word, wIndex, words) => {
const letters = word.split(' ').filter(Boolean);
letters.forEach((letter, lIndex) => {
[...letter].forEach((symbol, sIndex) => {
events.push({
type: symbol === '.' ? 'dot' : 'dash',
duration: symbol === '.' ? unit : unit * 3,
});
if (sIndex < letter.length - 1) events.push({ type: 'gap', duration: unit });
});
if (lIndex < letters.length - 1) events.push({ type: 'gap', duration: effectiveUnit * 3 });
});
if (wIndex < words.length - 1) events.push({ type: 'gap', duration: effectiveUnit * 7 });
});
return events;
}
Every duration falls out of that one dotUnitSeconds value — change the WPM, and dots, dashes, and every gap scale together automatically instead of drifting out of ratio the way my first hardcoded version did.
I ended up documenting the full ratio breakdown, including the PARIS reference word math, on an accuracy and methodology page on the site — partly so I'd stop re-deriving it from memory every time I touched the code.
*Sound was the easy half. Light was harder.
*
Once audio synthesis was solid, I wanted the same timing engine to drive other outputs — because historically, that's exactly how Morse code has always worked. The rhythm doesn't care what's carrying it. A ship-to-ship signal lamp uses the identical timing ratios as a radio tone; it's just light instead of sound.
So I reused the same core engine to build a flashlight and screen-strobe generator, and later a vibration output for mobile devices, aimed partly at accessibility use cases — some assistive typing systems for people with limited motor control are built around exactly this kind of binary tap-length input. Reusing one timing core across sound, light, and vibration was the first moment the project actually felt like a coherent tool instead of three separate scripts glued together.
*Then people started asking for weirder things
*
Once the core translator worked, requests started rolling in that I hadn't planned for at all — someone wanted a Morse code tattoo design, someone else wanted Morse spelled out as a printable image for a wedding invitation. That's what led to building an image generator that exports clean PNG, SVG, and JPG versions of any message, and eventually a downloadable audio generator so people could save a WAV file instead of just playing it live in the browser.
None of these were things I set out to build. They came from watching what people actually typed into the translator and noticing the same requests showing up again and again.
*What surprised me most
*
I expected this project to be a straightforward encode/decode exercise. What it actually became was a small lesson in how much of "old" technology is really just very precise engineering that happened to predate computers. Morse code has survived almost two centuries not because it's simple, but because the ratios underneath it are exact enough to stay intelligible through static, low bandwidth, and — as I learned firsthand — through a developer who didn't read the spec closely enough on the first try.
If you want to hear the difference correct timing makes, the live translator is free to use — type anything and listen.
Top comments (0)