Most Morse code generators on the web do two things wrong: they render signals as plain periods (.) and hyphens (-)—which completely misrepresents the physical duration of the marks—and they play audio by chaining setTimeout calls.
If you have ever tried building a rhythm-based tool in the browser using setTimeout, you already know what happens. Within four or five words, main-thread jitter and browser timer throttling push the audio out of step with the UI.
While building Morse Universe, I wanted every tool on the site—the translator, the audio/video decoders, the 40-lesson Koch trainer, and the printable charts—to run off a single underlying engine and a single timetable. Whether the browser is playing a 600 Hz tone, lighting up an SVG bar drawn to true 1:3 scale, buzzing the phone's vibration motor, or blinking the rear camera flash on Android Chrome, every output has to stay in step to the millisecond.
Here is how the timing math, the Web Audio lookahead scheduler, and the hardware outputs work under the hood.
1. The Timing Math: ITU-R M.1677-1 and Farnsworth Spacing
International Morse code timing is defined by the ITU-R M.1677-1 standard. Everything is measured in multiples of a single base unit (one dit):
- Dit (short mark): 1 unit
- Dah (long mark): 3 units
- Intra-character gap (between marks of the same letter): 1 unit
- Inter-character gap (between letters): 3 units
- Word gap: 7 units
To convert Words Per Minute (WPM) into milliseconds, standard telegraphy uses the reference word "PARIS", which takes exactly 50 units (including the trailing word space). At 1 WPM, 50 units take 60,000 ms, which simplifies to:
Unit Duration (ms) = 1200 / WPM
At 20 WPM, a single dit lasts exactly 60 ms, and a dah lasts 180 ms.
Handling Farnsworth Timing
When beginners learn Morse at 5 WPM, they end up counting dots and dashes instead of recognizing the acoustic rhythm of each letter. In 1990, the ARRL adopted Farnsworth timing for code practice: characters are sent at full speed (say, 20 WPM) so their rhythm stays intact, while the gaps between letters and words are stretched by 2x or 3x.
Instead of recalculating durations on the fly during playback, the cleanest approach is to compile the input text into a flat, deterministic timetable before a single sound plays:
const MORSE_TABLE = {
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: "--.."
};
export function buildTimetable(text, wpm = 20, spacingMultiplier = 1) {
const ditMs = 1200 / wpm;
const letterGapMs = ditMs * 3 * spacingMultiplier;
const wordGapMs = ditMs * 7 * spacingMultiplier;
const events = [];
let cursorMs = 0;
const words = text.trim().toUpperCase().split(/\s+/);
words.forEach((word, wIndex) => {
const letters = [...word];
letters.forEach((char, lIndex) => {
const pattern = MORSE_TABLE[char];
if (!pattern) return;
[...pattern].forEach((mark, mIndex) => {
const durationMs = mark === "." ? ditMs : ditMs * 3;
events.push({
type: mark === "." ? "dit" : "dah",
char,
startMs: cursorMs,
durationMs,
endMs: cursorMs + durationMs
});
cursorMs += durationMs;
// 1-unit gap between marks inside a letter (never scaled by Farnsworth)
if (mIndex < pattern.length - 1) {
cursorMs += ditMs;
}
});
// 3-unit gap between letters
if (lIndex < letters.length - 1) {
cursorMs += letterGapMs;
}
});
// 7-unit gap between words
if (wIndex < words.length - 1) {
cursorMs += wordGapMs;
}
});
return { events, totalDurationMs: cursorMs, ditMs };
}
Because this timetable is just data, you can feed the exact same array to the audio engine, the SVG renderer, and the URL state serializer.
2. Eliminating "Key Clicks" on a 600 Hz Oscillator
If you start and stop an OscillatorNode abruptly, or snap a GainNode from 0 to 1 instantly, the speaker cone jumps mid-phase. That produces an audible popping sound known in radio as a key click.
To get a clean 600 Hz tone, keep a single oscillator running for the duration of the message and schedule the gain envelope on the AudioContext.currentTime hardware clock with a 4–5 ms rise and fall ramp:
export function playMorseAudio(audioCtx, events) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "sine";
osc.frequency.value = 600; // Standard 600 Hz CW pitch
gain.gain.value = 0;
osc.connect(gain);
gain.connect(audioCtx.destination);
const baseTime = audioCtx.currentTime + 0.05; // 50ms safety buffer
const rampSec = 0.004; // 4ms anti-click envelope
for (const ev of events) {
const startSec = baseTime + ev.startMs / 1000;
const endSec = baseTime + ev.endMs / 1000;
gain.gain.setValueAtTime(0, startSec);
gain.gain.linearRampToValueAtTime(1, startSec + rampSec);
gain.gain.setValueAtTime(1, endSec - rampSec);
gain.gain.linearRampToValueAtTime(0, endSec);
}
const lastEvent = events[events.length - 1];
const stopSec = baseTime + (lastEvent ? lastEvent.endMs / 1000 : 0) + 0.1;
osc.start(baseTime);
osc.stop(stopSec);
return { baseTime, stop: () => osc.stop() };
}
3. Syncing Visuals, Vibration, and the Rear Camera Flashlight
Once the Web Audio clock (audioCtx.currentTime) is running, the visual highlights shouldn't rely on independent timers. Instead, a requestAnimationFrame loop checks the elapsed time against baseTime and highlights whichever mark in events matches startMs <= elapsedMs < endMs.
Why 1:3 Visual Proportions Matter
On paper or a screen, typing .- with a standard font makes the dot and hyphen nearly the same width. When learners look at that and then listen to the audio, their eyes and ears disagree. Drawing a dit as a 1-unit bar and a dah as a 3-unit bar with a 1-unit gap between them makes the visual layout match the acoustic rhythm. (We use this same 1:3 proportional drawing engine in our printable Morse code worksheet generator so paper sheets match what students hear.)
Blinking the Hardware Flashlight on Android Chrome
One of the more fun browser APIs to wire up to the timetable is the phone's rear camera flash. On Android phones running Chrome, you can request a video track with the "environment" facing camera and toggle the torch constraint as each mark starts and ends:
async function createTorchController() {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" }
});
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (!capabilities.torch) {
track.stop();
throw new Error("Torch constraint not supported on this device.");
}
return {
setLight: (on) => track.applyConstraints({ advanced: [{ torch: Boolean(on) }] }),
release: () => track.stop()
};
}
A quick heads-up if you implement this: iPhones still do not let a web page switch the rear flash via MediaStreamTrack, so make sure to feature-detect capabilities.torch and degrade gracefully to a screen flash or haptic vibration (navigator.vibrate).
Wrapping Up
Keeping everything—from the translator and live microphone decoder to the printable reference trees—on a single client-side engine without cookies or server round-trips keeps the UI fast and predictable. If you want to test the live timing ruler, play with Farnsworth spacing, or try the flashlight output on Android, you can check it out at morseuniverse.com.
73! (Traditional radio operator sign-off for "best regards")
Top comments (0)