Morse Code on Arduino: tone(), Timing, and a Complete Buzzer Sketch

2026-04-21 · Programming

If any programming platform was born to speak Morse, it is the Arduino. The code's entire specification is timing — a dot, then silence, then longer elements — and a microcontroller blinking and buzzing exact millisecond patterns is the closest a hobbyist gets to standing at a real telegraph key. A five-dollar part list and twenty lines of code produce something audible across the room.

This guide goes beyond copy-paste: it covers the wiring, the timing math that makes output actually recognizable, a complete working sketch, and the library-level traps (tone()'s timer conflicts, active versus passive buzzers) that explain why "my buzzer only clicks" is such a common forum post. Follow along and you will have your board calling SOS within ten minutes.

Everything uses International Morse — the full chart is your reference while transcribing the table, and the learn tool will train your ear to verify what you built.

What hardware do you need?

The parts list is minimal, and most of it is probably already in your starter kit:

The buzzer distinction matters enough to decide before you order: an active buzzer has an oscillator inside and makes one fixed pitch when given DC — it will still click out a recognizable rhythm but cannot be tuned. A passive buzzer needs a frequency, which is exactly what tone() provides, and sounds noticeably better. If your kit's buzzer made sound from a plain digitalWrite(HIGH), it is active.

  • Any Arduino board — an Uno or Nano is plenty; the sketch uses under 2 KB of flash.
  • One passive buzzer element (more on active vs. passive in a moment).
  • One LED if you want a visual channel alongside audio — or just use the onboard LED on pin 13.
  • One 220-ohm resistor for the external LED, a breadboard, and a few jumper wires.
  • USB cable and the Arduino IDE — nothing else, no libraries to install.

How do you wire the buzzer and LED?

Wiring is two independent output circuits. The buzzer's positive leg goes to a PWM-capable pin, its other leg to ground; the LED and resistor form the classic output pair:

Pin 9 for the buzzer is a habit rather than a law: tone() outputs a square wave on any digital pin, but choosing a PWM pin keeps future projects — volume fades, envelope shaping — open. The onboard LED on pin 13 already has a resistor, so the external LED is optional; it exists because a silent practice mode (LED only) is genuinely useful for late-night debugging and for signaling across a quiet room, the same way the light-signal tool works in browser form.

ComponentArduino pinOther connection
Passive buzzer (+)D9
Passive buzzer (−)GND
LED anode (+, long leg)D13 via 220 Ω
LED cathode (−, short leg)GND

What timing rules does the sketch follow?

One constant governs everything: the dot duration. From the International standard, a dash is three dots, the gap inside a character is one dot, between letters three, and between words seven. Speed in words per minute relates by the same rule used everywhere from the C implementation to radio licensing exams: WPM = 1200 / dot-milliseconds.

So DOT_MS = 100 yields 12 WPM — a comfortable learning speed. Drop to 60 for 20 WPM once your ear improves, or raise to 200 for teaching a class letter by letter. Because every duration in the sketch is a multiple of DOT_MS, that single constant is your speed knob; changing it retunes the whole program consistently, which is exactly how the timing was designed a century and a half before microcontrollers existed.

One refinement worth knowing about even if you never implement it: Farnsworth timing, the standard method for teaching Morse to humans. Instead of stretching every unit equally, Farnsworth sends each character at full speed but widens the gaps between characters and words. The characters keep their crisp, recognizable rhythm while the learner gets extra thinking time between them. On an Arduino it costs two extra constants — a character gap and a word gap no longer derived from DOT_MS — and it makes the difference between output a beginner can copy and output that blurs into noise.

What does the complete sketch look like?

Here is the entire program. It is deliberately compact — table, one element sender, one character sender, one message sender:

When you read the listing, watch how the gaps compose: sendElement always ends with a one-unit silence, so the letter gap only needs two more (1 + 2 = 3), and the word gap four more on top of the letter gap's three. Getting these additions wrong by one unit is the difference between machine Morse that operators can copy and machine Morse that sounds drunk — verify against the audio reference until they match.

  • const int BUZZER = 9;
  • const int LED = 13;
  • const int DOT_MS = 100; // 1200 / 100 = 12 WPM
  • const char *CODE[26] = {
  • ".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....",
  • "..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.",
  • "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-",
  • "-.--", "--.."
  • };
  • void sendElement(int ms) {
  • tone(BUZZER, 700);
  • digitalWrite(LED, HIGH);
  • delay(ms);
  • noTone(BUZZER);
  • digitalWrite(LED, LOW);
  • delay(DOT_MS); // intra-character gap
  • }
  • void sendChar(char c) {
  • if (c == ' ') { delay(DOT_MS * 4); return; } // word gap: 3+4=7
  • if (c < 'A' || c > 'Z') return;
  • const char *code = CODE[c - 'A'];
  • for (int i = 0; code[i]; i++)
  • sendElement(code[i] == '.' ? DOT_MS : 3 * DOT_MS);
  • delay(DOT_MS * 2); // letter gap: 1+2=3
  • }
  • void send(const char *msg) {
  • for (int i = 0; msg[i]; i++)
  • sendChar(toupper(msg[i]));
  • }
  • void setup() {
  • pinMode(LED, OUTPUT);
  • send("SOS");
  • }
  • void loop() { }

How does tone() work — and when does it bite?

tone(pin, frequency) starts a hardware square wave driven by a timer, and noTone() stops it — no library, no CPU overhead per cycle. The fine print, learned by everyone eventually: tone() uses Timer 2, so libraries that also claim Timer 2 (certain servo and PWM expansions) conflict with it, producing silence, clicking, or wrong pitches. If your buzzer misbehaves only after adding another library, this is why.

The other classic surprise: calling tone() twice without noTone() between them can leave the pin in a stuck state, so always pair them as the sketch does — on, delay, off, gap. And on the ESP32 boards, tone() behaves differently enough that many guides use ledcWriteTone instead; the timing logic above transfers unchanged, only the two sound lines change. A final detail for the perfectionists: stick to one frequency for the whole message — operators identify letters by rhythm alone, and a "melodic" multi-pitch sketch is significantly harder to copy than a monotone one.

How do you make it non-blocking?

The sketch above is intentionally blocking — delay() runs the whole show, which is perfect for learning and hopeless for anything interactive. The standard upgrade replaces delays with a millis() state machine: keep an index into the message and a nextChangeAt timestamp, and on each loop() pass check whether it is time to start or stop the current element. Nothing waits; the MCU can simultaneously read a button, poll a sensor, or serve serial input.

That refactor is the single best exercise in this project — it converts a toy into an architecture you will reuse for every timing-driven build afterward, and it is the stepping stone to the real prize: a tap-code key decoder where a push button is the input and your sketch times the presses to distinguish dots from dashes, letter gaps from word gaps.

Can you send messages live from the Serial Monitor?

Hardcoding the message into setup() is fine for day one, but the obvious upgrade is typing messages at runtime. The Serial Monitor makes this a five-line change to loop() — and it finally makes the project feel like a real telegraph station rather than a demo:

Open the monitor at 9,600 baud, set the line-ending dropdown to Newline, and anything you type plays immediately — type your name, a friend's name, or SOS and hear it back. Note the two details that trip people: readStringUntil('\n') blocks with a default one-second timeout if the line ending is not actually set to Newline (symptom: the message plays with a strange pause and a truncated tail), and String on small boards quietly allocates heap, so for a long-running installation prefer a fixed char buffer filled byte by byte. Neither matters for a desk project; both matter the day you bolt this into something permanent.

  • void loop() {
  • if (Serial.available()) {
  • String msg = Serial.readStringUntil('\n');
  • msg.trim();
  • if (msg.length()) send(msg.c_str());
  • }
  • }

What can you build next?

Once the core transmits reliably, the project tree branches fast: a garden-path SOS beacon for hiking kits (loop send("SOS") with a long pause); light-link communication between two rooms using LEDs and photoresistors — the physical ancestor of the light tool; a practice oscillator that beeps random letters for Morse study, scored against the learn curriculum; a secret-message box that only opens after it hears the right tapped password; or a name encoder that plays Emma or Liam as a doorbell chime.

For the software side of the same skills, the Python version generates the WAV files this hardware plays, and the story of the code's inventors makes a nice thing to listen to while your board blinks away on the desk.

Morse Prosigns Explained: AR, SK, BT, KNHam Radio Q-Codes Explained: QSO, QTH, Q