Every free Morse code translator I tried had at least one dealbreaker. Some had no audio at all. Some paywalled the playback. Some wouldn't let me set Farnsworth spacing. And none of them could export the audio as a WAV file without a server round-trip.
So I spent a weekend building one that fixes all four, and turned out to be a nice little engineering puzzle.
Live tool: if you want the whole story.
Once the timing rules are clear, the encoded Morse string is easy to schedule as audio.
The trick — unifying playback and WAV export
Here's the part I got wrong on the first pass. I wrote the realtime playback first, using a Web Audio API oscillator that scheduled tones on the audio graph. Worked great.
Then I added WAV export. I wrote it with an OfflineAudioContext + a separate scheduler. Worked... almost.
The exported WAV had subtle off-by-one gaps between letters that the browser preview didn't have. Different code paths, different timing bugs.
The fix was to unify the schedule generation into one function that both consumers read from:
js
function buildSchedule(pattern, timings) {
const events = [];
let t = 0.05; // small offset from t=0 so
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
const next = pattern[i + 1];
if (ch === '.' || ch === '-') {
const dur = ch === '.' ? timings.dit : timings.dah;
events.push({ t, dur });
t += dur;
// Only add intra-symbol gap if next c
// in the same letter. Space and slash
if (next === '.' || next === '-') t += timings.intra;
} else if (ch === ' ') {
t += timings.letter;
} else if (ch === '/') {
t += timings.word;
}
}
return { events, totalDur: t + 0.1 };
}
One schedule builder. Two consumers. Both realtime and offline reads the same list of {t, dur} events, so "play then download" produces the same audio down to the sample.
Scheduling a tone
Same helper for both paths:
js
function scheduleToneOn(ctx, t, dur, freq) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const fade = Math.min(0.005, dur / 4);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.4, t + fade);
gain.gain.setValueAtTime(0.4, t + Math.max(dur - fade, fade));
gain.gain.linearRampToValueAtTime(0, t + d
osc.connect(gain).connect(ctx.destination);
osc.start(t);
osc.stop(t + dur + 0.02);
return osc;
}
The gain envelope with a 5 ms fade in/out maave at 600 Hz produces audible clicks at the
start and end of every dit. The linear ramp
PCM 16-bit LE WAV encoder
OfflineAudioContext gives you an AudioBufferg()`. To download it, encode as WAV manually —
no library needed, the RIFF header is small:
function encodeWav(audioBuffer) {
const numCh = 1;
const sampleRate = audioBuffer.sampleRate;
const samples = audioBuffer.getChannelData
const bytesPerSample = 2;
const blockAlign = numCh * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSi
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, numCh, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
let off = 44;
for (let i = 0; i < samples.length; i++, off += 2) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return buffer;
}
Wrap the returned ArrayBuffer in a Blob({ type: 'audio/wav' }), create an object URL, click a temporary anchor, and the browser saves the file. No server touched.
Farnsworth done as two sliders
Every free translator I saw either skipped F. Real training needs two knobs:
Character speed — how fast each letter sounds
Text speed — the effective gap between letters
Beginners drill 20 WPM characters with 8 WPMshapes, but enough thinking room. That's how
ARRL and every serious trainer teaches it. Tfor this, not one.
In code, this changes the letter/word gap unmbol dit unit at character speed:
const unit = 1.2 / wpm; // char speed
const gapUnit = 1.2 / Math.min(farnsWpm, wpm); // text speed
return {
dit: unit,
dah: unit * 3,
intra: unit,
letter: gapUnit * 3,
word: gapUnit * 7,
freq: parseInt(freqSlider.value, 10),
};
Prosign parser — SOS the correct way
A prosign is a Morse pattern that doesn't spal signal. SOS is the most famous. On the air
the proper SOS is one nine-symbol shape (`.. between the S, O, and S. Most Morsetranslators send SOS as three separate letters with 3-unit gaps in between, which is technically not a distress signal at all.
Fixed it with angle-bracket syntax. Type <S , , , ` and each
renders as one run-together shape:
function encode(input) {
return input.toUpperCase().split(' ').map((word) => {
const m = word.match(/^<([A-Z]+)>$/);
if (m && PROSIGNS[m[1]]) return PROSIGNS
return word.split('').map((ch) => TEXT_Tlean).join(' ');
}).filter(Boolean).join(' / ');
}
The decoder does the reverse — a token that matches a known prosign pattern with no internal spaces comes back as <XXX> in angle brackets, so a round-trip i
Puzzle share mode
Standard share links carry the plain text. Fine for showing a friend what a phrase looks like in Morse. Bad for setting a decoding challenge — the URL leaks the answer.
Puzzle share links carry only the Morse pattrce text:
learnmorsy.com/translator/?puzzle=1&m=....%20.%20.-..%20.-..%20---
The recipient's browser detects puzzle=1, -only main input, and shows a separate guess
field with Check and Reveal buttons. Anyone it.
Wrap up
Whole thing is under 500 lines of vanilla JS. No framework, no bundler, no backend. It runs offline once the page has loaded.
Try it: covers why PARIS is the reference word and how Farnsworth actually works.
There's a Koch-method Morse trainer app I'm shipping separately (iOS + Android + Apple Watch), but the translator is standalone and stays free. Feedback welcome, especially decoder edge cases I might have missed.
SOCIAL SHARE CARD GENERATOR