Build a Morse Code Practice App with the Web Audio API: Oscillator, Timing Engine, and Real-Time Feedback
Most Morse code trainers are either toy web pages with sloppy timing or native apps you have to install. There is a better option: the browser already ships everything you need for a serious practice tool. The Web Audio API gives you sample-accurate sound synthesis, and a few dozen lines of JavaScript give you a timing engine precise enough for real training. In this tutorial you will build a working Morse practice app — oscillator, timing engine, encoder, and a feedback loop that scores your keying — and learn the DSP fundamentals underneath.
Why the browser is a legitimate Morse instrument
Morse code is audio-first. Learning it from charts trains your eyes; fluency lives in your ears. A practice app therefore stands or falls on its audio quality, and that is exactly where the Web Audio API shines: it schedules sound on the audio thread with sub-millisecond precision, far tighter than anything setTimeout can deliver. Browsers also remove every installation barrier, which matters for a learning tool people open for five minutes a day.
Part 1: The oscillator — clean dits and dahs
A Morse tone is just a sine wave gated on and off, typically between 600 and 750 Hz (the sweet spot for human hearing and for most radio sidetones). The naive approach — starting and stopping an oscillator — produces audible clicks, because the waveform is chopped mid-cycle. The fix is envelope shaping: ramp the gain up and down over a few milliseconds so each element begins and ends at zero amplitude.
const ctx = new (window.AudioContext || window.webkitAudioContext)(); const FREQ = 700; // sidetone frequency in Hz const RAMP = 0.005; // 5 ms click-free envelope function playTone(startTime, duration) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = FREQ; // Raised edges: ramp up, hold, ramp down — no clicks gain.gain.setValueAtTime(0, startTime); gain.gain.linearRampToValueAtTime(0.5, startTime + RAMP); gain.gain.setValueAtTime(0.5, startTime + duration - RAMP); gain.gain.linearRampToValueAtTime(0, startTime + duration); osc.connect(gain).connect(ctx.destination); osc.start(startTime); osc.stop(startTime + duration + 0.02); } Note that everything is scheduled against ctx.currentTime, the audio clock — never against wall-clock timers. This is the single most important habit in Web Audio programming: schedule ahead on the audio timeline and let the audio thread handle precision.
Part 2: The timing engine — the PARIS standard
Morse timing is defined in units, and the reference word PARIS sets the scale: at N words per minute, one unit lasts 1200 / N milliseconds. The rules:
- Dit = 1 unit, dah = 3 units
- Gap between elements of one character = 1 unit
- Gap between characters = 3 units
- Gap between words = 7 units
function unitMs(wpm) { return 1200 / wpm; } const MORSE = { 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: '--..', '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....', '6': '-....', '7': '--...', '8': '---..', '9': '----.', '0': '-----', ' ': '/' }; function scheduleText(text, wpm, when) { const u = unitMs(wpm) / 1000; // seconds let t = when; for (const ch of text.toUpperCase()) { const code = MORSE[ch]; if (!code) continue; if (code === '/') { t += 7 * u; continue; } // word gap for (const sym of code) { playTone(t, (sym === '.' ? 1 : 3) * u); // dit or dah t += (sym === '.' ? 1 : 3) * u + 1 * u; // + intra-char gap } t += 2 * u; // top up to the full 3-unit inter-character gap } return t; } // Usage: play "SOS" at 18 WPM starting 0.1 s from now scheduleText('SOS', 18, ctx.currentTime + 0.1); Read the timing loop carefully: after each element we add its duration plus one unit of gap, then add two more units after the last element to reach the standard three-unit character gap. The word gap adds seven full units. This is exact, standard timing — the same rhythm a well-adjusted straight key produces.
The Farnsworth trick, in code
Beginners learn faster when characters arrive at full speed but with stretched gaps between them — the Farnsworth method. It is a two-line change: keep the element timing at the character speed, but multiply the gaps.
function scheduleFarnsworth(text, charWpm, effectiveWpm, when) { // Character speed sets element durations; effective speed sets gaps. const uChar = unitMs(charWpm) / 1000; const uEff = unitMs(effectiveWpm) / 1000; let t = when; for (const ch of text.toUpperCase()) { const code = MORSE[ch]; if (!code) continue; if (code === '/') { t += 7 * uEff; continue; } for (const sym of code) { playTone(t, (sym === '.' ? 1 : 3) * uChar); t += (sym === '.' ? 1 : 3) * uChar + 1 * uChar; } t += 3 * uEff - 1 * uChar; // stretch the character gap } return t; } At 20 WPM character speed with 8 WPM effective speed, learners hear true 20-WPM sound shapes with thinking room between letters — the fastest known path to ear fluency.
Part 3: The feedback loop — score the user's keying
Playing code is only half a trainer. The other half listens: the user keys with a mouse press, touchscreen, or keyboard, and the app measures each press, classifies it as dit or dah, and scores accuracy. The threshold is the midpoint between one and three units — two units.
let keyOsc = null, keyGain = null; let pressStart = 0; function keyDown() { ctx.resume(); // browsers require a user gesture to start audio keyOsc = ctx.createOscillator(); keyGain = ctx.createGain(); keyOsc.frequency.value = FREQ; keyGain.gain.setValueAtTime(0, ctx.currentTime); keyGain.gain.linearRampToValueAtTime(0.5, ctx.currentTime + RAMP); keyOsc.connect(keyGain).connect(ctx.destination); keyOsc.start(); pressStart = performance.now(); } function keyUp() { const ms = performance.now() - pressStart; const u = unitMs(currentWpm); keyGain.gain.linearRampToValueAtTime(0, ctx.currentTime + RAMP); keyOsc.stop(ctx.currentTime + RAMP + 0.02); const symbol = ms < 2 * u ? '.' : '-'; // classify by the 2-unit threshold onUserSymbol(symbol, ms); } onUserSymbol appends the symbol to the user's current character, and when a pause longer than three units elapses, the character is complete and compared against the target. Scoring per character — rather than per press — teaches rhythm, which is the actual skill. A simple, motivating score: percentage of characters copied correctly at the session's WPM, with a rolling best.
Part 4: Polish that makes people practise
Four small features separate a demo from a tool people return to:
- Visual key. A big on-screen key that depresses with the pointer gives tactile feedback on touch devices, where most casual practice happens.
- Adaptive speed. Raise the WPM by one when the user scores above 90% for ten consecutive characters; drop it by two below 70%. The Farnsworth gap absorbs the difficulty jumps.
- Session stats. Characters copied, accuracy, current and best WPM — persisted in
localStorage. Visible progress is the strongest retention mechanic in skill apps. - Reference ear-training mode. Play a random character, show four options, and let the user identify it by ear. This "copy" drill builds receiving skill, which most keying apps neglect.
Putting it together
The full app is under 300 lines: the oscillator with click-free envelopes, the PARIS timing engine with Farnsworth support, the encoder, and the keying feedback loop. Every piece schedules on the audio clock, so timing stays exact even when the main thread is busy rendering. That precision is not academic — at 20 WPM a unit is 60 ms, and jitter of even 10 ms is audible to a trained ear.
If you want to sanity-check your timing against a reference, a free online Morse code translator with audio playback is a handy baseline: generate the same text at the same WPM and compare the rhythm ear-to-ear.
Frequently asked questions
Why does my Morse audio click?
You are almost certainly starting and stopping the oscillator (or gain) discontinuously. Always ramp the gain over 3–5 ms at both edges of every element, as in playTone above.
Can I use square waves for a more "radio" sound?
You can — set osc.type = 'square' — but sine waves are kinder for long practice sessions and closer to a well-filtered receiver. Keep the frequency between 600 and 750 Hz either way.
Why schedule on the audio clock instead of setTimeout?
setTimeout in a busy tab can jitter by tens of milliseconds; the Web Audio clock is sample-accurate. At 20+ WPM, timer jitter smears dits into dahs. Schedule ahead with ctx.currentTime offsets and the timing stays exact.
How do I handle the browser's autoplay policy?
Audio contexts start suspended until a user gesture. Call ctx.resume() inside your first pointer/key handler — the key-down handler is the natural place — and everything after that just works.
What WPM should a beginner start at?
15–20 WPM character speed with Farnsworth gaps stretched to 5–8 WPM effective. Slow characters teach counting; fast characters with wide gaps teach hearing.
Morse Zone (morsezone.com) is a free online Morse code translator with audio playback and learning tools — a useful reference for checking your app's timing and a quick way to hear any text as Morse.

