I recently built a full-featured Morse code translator (Morse Code Translator) and learned some interesting lessons about audio generation, encoding algorithms, and Next.js 15's new features. This post breaks down the technical implementation and challenges I encountered.
Why Morse Code in 2025?
Before diving into the code, you might wonder: why build a Morse code tool in 2025?
Beyond the nostalgia factor, Morse code is actually a fascinating case study in variable-length encoding. Frequently used letters get shorter codes (E = ".", T = "-"), which later inspired Huffman coding algorithms used in modern compression. Plus, it's still actively used in:
- Amateur radio (ham radio) communication
- Aviation identifier beacons
- Assistive technology for people with disabilities
- Emergency communications when modern systems fail
Tech Stack
- Next.js 15 (App Router + Turbopack)
- React 19 (Server Components)
- TypeScript (strict mode)
- Tailwind CSS 4 (pre-release)
- Web Audio API (for sound generation)
The Morse Code Encoding Logic
Building the Translation Map
The core of any Morse translator is the character-to-code mapping. Here's the elegant data structure:
// lib/morse.ts
export const MORSE_CODE_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': '...--',
'4': '....-', '5': '.....', '6': '-....', '7': '--...',
'8': '---..', '9': '----.',
'.': '.-.-.-', ',': '--..--', '?': '..--..',
'/': '-..-.', ' ': '/'
};
// Create reverse map for decoding
export const MORSE_TO_TEXT_MAP = Object.entries(MORSE_CODE_MAP)
.reduce((acc, [char, morse]) => {
acc[morse] = char;
return acc;
}, {} as Record<string, string>);
Text to Morse Conversion
The encoding function handles edge cases and preserves word spacing:
export function textToMorse(text: string): string {
return text
.toUpperCase()
.split('')
.map(char => {
if (char === ' ') return '/'; // Word separator
return MORSE_CODE_MAP[char] || '';
})
.filter(Boolean)
.join(' '); // Letter separator
}
Morse to Text Conversion
Decoding is trickier because we need to handle word boundaries:
export function morseToText(morse: string): string {
return morse
.split(' / ') // Split by word separator
.map(word =>
word
.split(' ') // Split by letter separator
.map(code => MORSE_TO_TEXT_MAP[code] || '')
.join('')
)
.join(' ');
}
Generating Morse Audio with Web Audio API
This was the most challenging part. The Web Audio API is powerful but has quirks across browsers.
Audio Context Setup
// utils/audioGenerator.ts
export class MorseAudioGenerator {
private audioContext: AudioContext;
private frequency: number = 600; // Hz
private dotDuration: number = 0.06; // seconds
constructor(wpm: number = 20) {
this.audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
// Calculate timing based on words per minute
// Standard: "PARIS " = 50 dot durations
this.dotDuration = 1.2 / wpm;
}
private get dashDuration() {
return this.dotDuration * 3;
}
private get symbolGap() {
return this.dotDuration;
}
private get letterGap() {
return this.dotDuration * 3;
}
private get wordGap() {
return this.dotDuration * 7;
}
playTone(duration: number, startTime: number): void {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = this.frequency;
// Smooth attack and release to prevent clicks
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.005);
gainNode.gain.setValueAtTime(0.3, startTime + duration - 0.005);
gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
async playMorse(morseCode: string): Promise<void> {
let currentTime = this.audioContext.currentTime;
for (let i = 0; i < morseCode.length; i++) {
const symbol = morseCode[i];
if (symbol === '.') {
this.playTone(this.dotDuration, currentTime);
currentTime += this.dotDuration + this.symbolGap;
} else if (symbol === '-') {
this.playTone(this.dashDuration, currentTime);
currentTime += this.dashDuration + this.symbolGap;
} else if (symbol === ' ') {
currentTime += this.letterGap;
} else if (symbol === '/') {
currentTime += this.wordGap;
}
}
// Wait for playback to complete
const totalDuration = (currentTime - this.audioContext.currentTime) * 1000;
await new Promise(resolve => setTimeout(resolve, totalDuration));
}
generateWAV(morseCode: string): Blob {
const sampleRate = 44100;
const samples: Float32Array[] = [];
for (const symbol of morseCode) {
if (symbol === '.') {
samples.push(this.generateTone(this.dotDuration, sampleRate));
samples.push(this.generateSilence(this.symbolGap, sampleRate));
} else if (symbol === '-') {
samples.push(this.generateTone(this.dashDuration, sampleRate));
samples.push(this.generateSilence(this.symbolGap, sampleRate));
} else if (symbol === ' ') {
samples.push(this.generateSilence(this.letterGap, sampleRate));
} else if (symbol === '/') {
samples.push(this.generateSilence(this.wordGap, sampleRate));
}
}
// Combine all samples
const totalLength = samples.reduce((sum, arr) => sum + arr.length, 0);
const combinedSamples = new Float32Array(totalLength);
let offset = 0;
for (const sample of samples) {
combinedSamples.set(sample, offset);
offset += sample.length;
}
return this.createWavBlob(combinedSamples, sampleRate);
}
private generateTone(duration: number, sampleRate: number): Float32Array {
const samples = Math.floor(duration * sampleRate);
const buffer = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
// Apply envelope to prevent clicks
const envelope = this.getEnvelope(i, samples);
buffer[i] = Math.sin(2 * Math.PI * this.frequency * i / sampleRate) *
envelope * 0.3;
}
return buffer;
}
private generateSilence(duration: number, sampleRate: number): Float32Array {
return new Float32Array(Math.floor(duration * sampleRate));
}
private getEnvelope(sample: number, totalSamples: number): number {
const attackSamples = Math.floor(0.005 * 44100);
const releaseSamples = Math.floor(0.005 * 44100);
if (sample < attackSamples) {
return sample / attackSamples;
} else if (sample > totalSamples - releaseSamples) {
return (totalSamples - sample) / releaseSamples;
}
return 1;
}
private createWavBlob(samples: Float32Array, sampleRate: number): Blob {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
// WAV header
this.writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
this.writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
// PCM samples
let offset = 44;
for (let i = 0; i < samples.length; i++, offset += 2) {
const sample = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, sample * 0x7FFF, true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
private writeString(view: DataView, offset: number, string: string): void {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
React Component Implementation
Here's the main translator component using React 19 hooks:
'use client';
import { useState, useCallback, useMemo } from 'react';
import { textToMorse, morseToText } from '@/lib/morse';
import { MorseAudioGenerator } from '@/utils/audioGenerator';
export default function MorseTranslator() {
const [inputText, setInputText] = useState('');
const [isTextToMorse, setIsTextToMorse] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [wpm, setWpm] = useState(20);
const morseCode = useMemo(() => {
return isTextToMorse ? textToMorse(inputText) : inputText;
}, [inputText, isTextToMorse]);
const handleSwapDirection = () => {
setIsTextToMorse(!isTextToMorse);
setInputText('');
};
const handlePlayAudio = async () => {
if (isPlaying || !morseCode) return;
setIsPlaying(true);
const generator = new MorseAudioGenerator(wpm);
try {
await generator.playMorse(morseCode);
} catch (error) {
console.error('Audio playback failed:', error);
} finally {
setIsPlaying(false);
}
};
const handleDownload = () => {
if (!morseCode) return;
const generator = new MorseAudioGenerator(wpm);
const blob = generator.generateWAV(morseCode);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'morse-code.wav';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={isTextToMorse ? "Enter text..." : "Enter morse code..."}
className="w-full p-4 border rounded-lg"
rows={5}
/>
<div className="flex gap-4">
<button onClick={handleSwapDirection}>
Swap Direction
</button>
<button onClick={handlePlayAudio} disabled={isPlaying}>
{isPlaying ? 'Playing...' : 'Play Audio'}
</button>
<button onClick={handleDownload}>
Download WAV
</button>
</div>
<div className="p-4 bg-gray-100 rounded-lg">
<code className="font-mono">{morseCode || 'Output will appear here...'}</code>
</div>
</div>
);
}
Challenges and Solutions
1. Cross-Browser Audio Compatibility
Problem: Audio timing varied between Chrome, Firefox, and Safari.
Solution: Use audioContext.currentTime
for precise scheduling instead of setTimeout
. Apply smooth attack/release envelopes to prevent clicking sounds.
2. Turbopack Hot Module Replacement
Problem: HMR occasionally failed with complex component trees.
Solution: Simplify component structure and avoid circular dependencies. Restart dev server when HMR hangs.
3. WAV File Generation Performance
Problem: Generating long Morse messages caused UI freezes.
Solution: Consider moving to Web Workers for processing (future enhancement). Currently acceptable for messages under 1000 characters.
4. Mobile Audio Playback
Problem: iOS requires user interaction before playing audio.
Solution: Initialize AudioContext
on first button press. Show clear play button instead of auto-playing.
Performance Optimizations
// Memoize expensive calculations
import { useMemo } from 'react';
const morseOutput = useMemo(() => {
return textToMorse(inputText);
}, [inputText]);
// Debounce real-time conversion
import { useDebounce } from '@/hooks/useDebounce';
const debouncedInput = useDebounce(inputText, 300);
Key Timing Standards
Morse code timing follows strict ratios based on the "PARIS" standard:
Element | Duration | Ratio |
---|---|---|
Dot | 1 unit | 1 |
Dash | 3 units | 3 |
Intra-character gap | 1 unit | 1 |
Inter-character gap | 3 units | 3 |
Word gap | 7 units | 7 |
At 20 WPM (words per minute), the word "PARIS " (including trailing space) should take exactly 3 seconds, giving us 50 dot durations = 3000ms, or 60ms per dot unit.
Next Steps
Future improvements I'm considering:
- PWA support for offline usage
- Web Workers for audio processing
- Visual waveform display while playing
- Practice mode with randomized lessons
- Open-sourcing the core encoding logic
Key Takeaways
- Variable-length encoding is elegant and still relevant
- Web Audio API is powerful but requires careful timing management
- Turbopack speeds up builds significantly but still has rough edges
- Educational tools don't have to be boring – modern web tech makes them engaging
Try It Out
Check out the live version at Morse Code Translator and let me know what you think!
If you're interested in the full source code or want to contribute ideas, drop a comment below. I'm considering open-sourcing parts of the project.
What encoding or audio projects have you built? Share your experiences in the comments!
Happy coding! 🎵📡
Top comments (0)