Morse Code Encoder/Decoder: From Telegraph to Web Audio API
Morse Code Encoder/Decoder: From Telegraph to Web Audio API#
I recently built a Morse code tool and dove into this 180-year-old encoding system. Looks simple, but the implementation has depth.
The Essence of Morse Code#
Morse code is the ancestor of binary encoding. Only two states: dot (short signal) and dash (long signal). Like 0 and 1 in computing.
The international Morse code character map:
const MORSE_MAP: Record<string, string> = {
'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': '...--',
// ... other characters
}
Notice the patterns:
- E and T are most common, so they have the shortest codes (dot and dash)
- Numbers are uniformly 5 signals, easy to distinguish
Text to Morse Code#
Core algorithm:
function textToMorse(text: string): string {
const upper = text.toUpperCase()
const parts: string[] = []
for (const ch of upper) {
const code = MORSE_MAP[ch]
if (code) {
parts.push(code)
} else if (ch === ' ') {
parts.push('/') // Word separator
} else {
// Unicode codepoint encoding for non-ASCII
const cp = ch.codePointAt(0)
if (cp && cp > 127) {
const digitStr = String(cp)
for (const d of digitStr) {
parts.push(MORSE_MAP[d] || d)
}
}
}
}
return parts.join(' / ') // Letters separated by /
}
Key details:
- Case insensitive: Convert to uppercase before lookup
- Word separation: Spaces become
/, standard Morse notation - Non-ASCII characters: Chinese characters use Unicode codepoint encoding
The Chinese Character Problem#
“你好” (hello) has Unicode codepoints 20320 and 22909:
你: U+4F60 → codepoint 20320 → ..--- ----- ...-- ..--- -----
好: U+597D → codepoint 22909 → ..--- ..--- ----. ----- ----.
Technically encodable, but not practical for real communication. Morse was designed for Latin alphabet.
Morse Code to Text#
Decoding requires a reverse mapping:
const REVERSE_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MORSE_MAP).map(([k, v]) => [v, k])
)
function morseToText(morse: string): string {
const clean = morse.replace(/\s*\/\/\s*/g, ' // ')
const letters = clean.split(/\s+/)
const result: string[] = []
for (const letter of letters) {
if (letter === '/') {
result.push(' ')
} else {
const ch = REVERSE_MAP[letter]
if (ch) result.push(ch)
}
}
return result.join('')
}
Decoding pitfalls:
- Separator variety: Users might input
/,//,/, need normalization - Invalid sequences:
...--.not found in map, skip silently - Case: Decoded result is always uppercase
Audio Playback: Web Audio API#
Morse was originally an auditory code. Seeing symbols isn’t enough—it needs to be heard.
const TIMING = {
dot: 100, // Dot duration (ms)
dash: 300, // Dash duration (3x dot)
signalGap: 100, // Gap between signals
letterGap: 300, // Gap between letters (3x dot)
wordGap: 700, // Gap between words (7x dot)
}
async function playMorse(morseString: string) {
const ctx = new AudioContext()
const now = ctx.currentTime
let time = now
const segments = morseString.split(/\s+/)
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (segment === '/') {
time += TIMING.wordGap / 1000
continue
}
const signals = segment.split('')
for (let j = 0; j < signals.length; j++) {
const s = signals[j]
const duration = s === '.' ? TIMING.dot : TIMING.dash
// Create oscillator and gain node
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.value = 700 // Standard Morse tone
gain.gain.setValueAtTime(0.5, time)
gain.gain.setValueAtTime(0, time + duration / 1000)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(time)
osc.stop(time + duration / 1000)
time += duration / 1000
if (j < signals.length - 1) {
time += TIMING.signalGap / 1000
}
}
if (i < segments.length - 1) {
time += TIMING.letterGap / 1000
}
}
}
Timing Standards#
International Telecommunication Union spec:
- Dot = 1 unit
- Dash = 3 units
- Gap between signals = 1 unit
- Gap between letters = 3 units
- Gap between words = 7 units
The code uses 100ms as the base unit, adjustable for skill level.
Audio Parameters#
- Frequency 700Hz: Most sensitive range for human ears, good penetration
- Sine wave: Clean tone, no harmonic interference
- Gain 0.5: Avoid clipping
Reverse Lookup Optimization#
Decoding needs Morse → Character mapping. Iterating every time is slow.
// Generate reverse map at initialization
const REVERSE_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MORSE_MAP).map(([k, v]) => [v, k])
)
// O(1) lookup
const char = REVERSE_MAP['...'] // 'S'
The reverse map simply swaps keys and values using Object.fromEntries.
Edge Cases#
1. Input Validation#
function isValidMorse(input: string): boolean {
return /^[.\-/\\\s]+$/.test(input)
}
Only dots, dashes, slashes, and spaces allowed.
2. Performance#
Avoid string concatenation for long texts:
// Slow: string concatenation
let result = ''
for (const ch of text) {
result += MORSE_MAP[ch] + ' '
}
// Fast: array + join
const parts: string[] = []
for (const ch of text) {
parts.push(MORSE_MAP[ch])
}
return parts.join(' / ')
3. Audio Interruption#
const abortRef = useRef(false)
// Stop playback
function handleStop() {
abortRef.current = true
if (audioContextRef.current) {
audioContextRef.current.close()
}
}
// Check in playback loop
for (const segment of segments) {
if (abortRef.current) break
// ...
}
When user clicks stop, immediately abort the audio generation loop.
The Result#
Based on this implementation, I built: Morse Code Converter
Features:
- Text ↔ Morse code bidirectional conversion
- Real-time audio playback
- Unicode character support
- Complete character reference table
Morse code is old, but its binary thinking still matters. From telegraph to modern digital communication, it’s all about encoding and decoding information.
Related: Base64 Encoder/Decoder | URL Encoder/Decoder