A Morse code player only needs a few pieces: an oscillator for the tone, a gain node to turn it on and off, and a schedule for the dots, dashes and gaps.
Here is a small Web Audio example you can run from a button click. The two things to watch are timing and the clicks you hear when a tone starts or stops too sharply.
The timing rules
Everything in Morse is measured in one unit, the length of a dot. At a given speed in words per minute (WPM), one unit in milliseconds is:
unit = 1200 / wpm
That comes from the standard word "PARIS", which is exactly 50 units long. So at 20 WPM a unit is 60 ms.
| Part | Units | At 20 WPM |
|---|---|---|
| Dot | 1 | 60 ms |
| Dash | 3 | 180 ms |
| Gap inside a letter | 1 | 60 ms |
| Gap between letters | 3 | 180 ms |
| Gap between words | 7 | 420 ms |
Step 1: a lookup table
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: "--..",
0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-",
5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.",
};
Step 2: schedule, do not wait
It is tempting to put a setTimeout between beeps. Those callbacks can run late when the main thread is busy. At 25 WPM, a dot lasts only 48 ms, so a small delay can change the rhythm.
Web Audio has its own sample-accurate clock, audioCtx.currentTime. Work out every on and off time up front and hand them all to the audio thread in one go.
function playMorse(text, { wpm = 20, freq = 600 } = {}) {
const ctx = new AudioContext();
const unit = 1.2 / wpm; // seconds
const ramp = 0.005; // 5 ms fade, explained below
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
gain.gain.value = 0;
osc.connect(gain).connect(ctx.destination);
let t = ctx.currentTime + 0.1; // small lead-in
for (const word of text.toUpperCase().trim().split(/\s+/)) {
for (const ch of word) {
const code = MORSE[ch];
if (!code) continue; // skip unsupported characters
for (const symbol of code) {
const len = symbol === "." ? unit : unit * 3;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.5, t + ramp);
gain.gain.setValueAtTime(0.5, t + len - ramp);
gain.gain.linearRampToValueAtTime(0, t + len);
t += len + unit; // symbol, then 1-unit gap
}
t += unit * 2; // letter gap: 3 units total
}
t += unit * 4; // word gap: 7 units total
}
osc.start();
osc.stop(t);
osc.onended = () => ctx.close();
}
Note how the gaps add up. After each symbol we add 1 unit. After a letter we add 2 more, making 3. After a word we add 4 more, making 7.
Step 3: kill the clicks
If you switch the gain straight from 0 to 0.5, you get a click at the start and end of every beep. The speaker cone jumps instantly, which sounds like a tick on top of the tone.
The short ramp in the code fades each beep in and out over 5 ms. That smooths the sudden change in amplitude and reduces the click.
Step 4: wire it up
<input id="msg" value="SOS">
<button id="play">Play</button>
<script>
document.getElementById("play").addEventListener("click", () => {
playMorse(document.getElementById("msg").value, { wpm: 20, freq: 600 });
});
</script>
Browsers only let audio start after a user gesture, which is why the AudioContext is created inside the click handler.
Ideas to take it further
- Farnsworth spacing. Keep letters at 20 WPM but stretch the gaps between them. Learners hear each letter as one sound pattern but get more thinking time. You only need to scale the letter and word gaps, not the symbols.
-
Light output. Use the same schedule to toggle a full-screen colour with
requestAnimationFrame. -
Export to WAV. Swap
AudioContextforOfflineAudioContext, render, then write the buffer out as a WAV file. - Decoding. Going the other way (microphone to text) is a much harder problem: you need pitch detection, an envelope follower and adaptive timing.
To try a full browser tool, open SimpleMorse. Its translator plays text as sound, light or vibration, supports Farnsworth spacing, and exports MP3 or WAV. You can also feed a recording of your generator into the audio decoder, which accepts a microphone or an audio file.
SimpleMorse with a short message ready to play or export.
Happy beeping. .-.. --- .-..

Top comments (0)