DEV Community

Enes Vural
Enes Vural

Posted on

I built a Morse code translator that renders WAV audio entirely in the browser

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: learnmorsy.com/translator

What I actually wanted from it

I've been slowly picking up CW (Morse code on ham radio) and kept needing to check what a specific word sounds like at a target WPM speed. The workflow I wanted:

  1. Type text.
  2. Hear it played at any speed from 5 to 30 WPM.
  3. Adjust the tone frequency (600 Hz is standard but sounds different on cheap earbuds vs decent headphones).
  4. Set Farnsworth spacing — independent character speed and text speed, the way real trainers teach it.
  5. Download the audio as a WAV so I can drill it on a walk without opening the tool again.

Nothing on the first page of Google search results did all five. So the constraint was clear: everything runs in the browser, no server, no signup, no ads.

The stack

  • Astro static site
  • Vanilla JavaScript for the translator itself, one file, no bundler
  • Web Audio API for playback
  • OfflineAudioContext + a small PCM 16-bit L
  • Tailwind 4 for the UI
  • Cloudflare Pages for hosting

The translator script is under 500 lines and out that was the right call — I ship changes
with a single file edit and a cache-bust que

Timing model

Morse code is a timing system, not a symbol ured in "units":

  • Dit: 1 unit ON
  • Dah: 3 units ON
  • Gap between symbols in a letter: 1 unit OFF
  • Gap between letters: 3 units OFF
  • Gap between words: 7 units OFF

The unit length is calibrated so the word Phat comes out to1200 / WPM` milliseconds per unit. So at 20 WPM, one unit is 60 ms. The full math is on the companion blog post 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;
}
Enter fullscreen mode Exit fullscreen mode

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),
};
Enter fullscreen mode Exit fullscreen mode

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(' / ');
}
Enter fullscreen mode Exit fullscreen mode

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---
Enter fullscreen mode Exit fullscreen mode

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: learnmorsy.com/translator

If you want the theory, the Morse timing math writeup 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.

Top comments (0)