While working on a collection of browser-based tools recently, I found myself needing a Morse code translator. Not because I'm secretly a ham radio enthusiast or planning to send distress signals from my basement, but because I kept running into situations where I needed to decode or encode Morse code quickly.
The existing solutions were... fine, I guess. But they were mostly mobile apps with more ads than functionality, or websites that felt like they were designed in 1998 and never updated. I wanted something that worked entirely in the browser, had a clean interface, and could actually play the audio tones — not just show the dots and dashes. Because apparently I enjoy reinventing wheels.
The "Simple" Problem That Wasn't
Here's the thing about Morse code translation: it sounds trivial. Map letters to dots and dashes, reverse the mapping, done. But once you start thinking about the details, it gets interesting.
First, there's the timing. Morse code isn't just about the symbols — it's about the spaces between them. A dit is one time unit, a dah is three units, the gap between letters is three units, and between words it's seven units. Get the timing wrong and your "translation" sounds like someone mashing a telegraph key with no rhythm.
Then there's the audio playback. The Web Audio API gives you incredible control, but it also has this tendency to make you feel like you're writing assembly language when all you want is a simple beep.
And finally, there's the bidirectional nature of the UI. Type text, get Morse. Type Morse, get text. Both happening in real-time, with the ability to swap directions. It's the kind of problem that seems simple until you actually try to build it.
The Architecture Decisions
Let me walk you through the key decisions I made, starting with the most important one: keeping everything in a single HTML file.
Why single-file? For a tool like this, a single-file architecture makes sense. There's no build step, no server required, and users can literally save the file and run it offline. It's the ultimate in portability. The trade-off is that you can't use any external libraries — everything has to be vanilla JavaScript and CSS. Which, honestly, is fine for this scope.
The Morse mapping. The core data structure is straightforward:
const MORSE_MAP = {
'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.',
'F': '..-.', 'G': '--.', 'H': '....', 'I': '..', 'J': '.---',
// ... rest of alphabet and numbers
};
The reverse mapping is derived programmatically — no need to maintain two separate objects that could drift out of sync.
The bidirectional conversion. This was trickier than I expected. When you're converting text to Morse, it's straightforward: uppercase everything, map each character, join with spaces. But going the other direction requires parsing the Morse code with its spaces and slashes:
function morseToText(morse) {
return morse
.split('/')
.map(word => word.trim().split(' ')
.map(code => REVERSE_MORSE[code] || '')
.join(''))
.join(' ');
}
Simple, but effective. The slash separates words, spaces separate letters.
The Audio Playback Challenge
This was where things got interesting. I wanted to play the Morse code as actual sound — the characteristic beeps you hear in old war movies. The Web Audio API can do this, but it requires careful timing management.
The first approach I tried was scheduling all the tones upfront using AudioContext's time-based scheduling. This worked, but it had a problem: if the user wanted to stop playback mid-way, I'd have to track every scheduled oscillator and disconnect them all. It got messy fast.
function playMorse(morseCode, wpm, frequency) {
const ditDuration = 1200 / wpm; // milliseconds
const context = new AudioContext();
let time = context.currentTime;
// Schedule all tones upfront
for (const char of morseCode) {
if (char === '.') {
playTone(context, time, ditDuration / 1000, frequency);
time += ditDuration / 1000;
} else if (char === '-') {
playTone(context, time, (ditDuration * 3) / 1000, frequency);
time += (ditDuration * 3) / 1000;
}
time += ditDuration / 1000; // gap between symbols
}
}
The better approach was to use setTimeout to schedule each tone individually, making it easy to cancel with clearTimeout when the user hits stop. The trade-off is less precise timing, but for a tool like this, that's acceptable.
When AI Actually Helped (And When It Didn't)
I'll be honest: I used AI assistance for parts of this project, and the experience was mixed.
Where AI excelled: The initial scaffolding. Describing the UI layout, the i18n system, and the basic conversion logic to Claude got me a solid foundation in minutes. The AI was particularly good at generating the complete Morse code mapping table — all 36 characters plus punctuation — without any errors. That's the kind of tedious, error-prone work that AI handles perfectly.
Where AI struggled: The audio playback timing. My first prompt to Claude produced code that technically worked but sounded wrong. The timing between letters was too short, making everything sound like one continuous tone. It took several iterations of "no, the gap between letters needs to be longer" before the AI finally got the timing right.
The real lesson? AI is great for generating boilerplate and well-known logic patterns, but you still need to understand the domain well enough to verify the output. The AI didn't know Morse code timing standards — I had to tell it.
The i18n Approach
Given that I'm building a collection of tools for a global audience, internationalization was non-negotiable. But I didn't want to pull in a full i18n library for what's essentially a two-language tool.
The solution was a lightweight dictionary-based approach:
const i18n = {
en: {
'title': 'Morse Code Translator',
'text-input': 'Text Input',
'morse-output': 'Morse Code',
// ...
},
zh: {
'title': '摩斯电码翻译器',
'text-input': '文本输入',
'morse-output': '摩斯电码',
// ...
}
};
The language detection follows a specific priority: URL parameter > browser language > default. It's not perfect — there's no way to handle languages beyond English and Chinese without adding more dictionaries — but it covers the main use cases.
The CSS That Made Me Question My Life Choices
Let me talk about the CSS for a moment. I wanted the tool to look modern and clean, with proper dark mode support. The approach was CSS variables with prefers-color-scheme:
:root {
--bg: #ffffff;
--text: #111827;
--primary: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a2e;
--text: #e2e8f0;
--primary: #60a5fa;
}
}
This worked perfectly... until I realized the accent-color property for range inputs doesn't respect CSS variables in all browsers. Classic "works on my machine" situation. The fix was to explicitly set the accent color using the variable, which works in modern browsers but required a fallback for older ones.
Lessons Learned
1. Timing precision matters more than you think. When I first built the audio playback, I was using setTimeout with the calculated durations. But the actual timing was off because setTimeout isn't precise — it fires when it can, not when you ask it to. For a tool that needs to generate Morse code at a specific WPM, this is a real problem. The solution was to use the Web Audio API's built-in scheduling whenever possible, falling back to setTimeout only when necessary.
2. Don't trust AI with domain-specific knowledge. The AI was confident when it generated the audio timing code, but it was wrong about the standard timings. Morse code has specific rules: dit = 1 unit, dah = 3 units, gap between symbols = 1 unit, gap between letters = 3 units, gap between words = 7 units. The AI didn't know these standards and made up its own timing scheme.
3. Single-file architecture is underrated. For small tools, the simplicity of a single HTML file is unmatched. No build process, no dependencies, no deployment headaches. Just open the file and it works.
The Result
During this process, I built a small browser-based tool to make this workflow easier. It handles bidirectional translation, plays audio with adjustable speed and tone, includes a complete reference chart, and works entirely offline. You can find it here: Craftvo's Morse Code Translator
The code is about 400 lines of JavaScript, 200 lines of CSS, and one HTML file. It's not the most impressive engineering feat, but it solves a real problem in a clean way.
Final Thoughts
Building tools like this reminds me that sometimes the most interesting engineering problems come from the simplest requirements. Morse code translation sounds trivial, but it touches on character encoding, audio synthesis, timing precision, and internationalization. Each of these presents its own challenges, and solving them together makes for a satisfying engineering exercise.
And yes, I did test it by sending "SOS" in Morse code. Because I'm a responsible engineer who tests thoroughly. That's my story, and I'm sticking to it.
Tags: webdev, javascript, webaudio, tools, i18n
Top comments (0)