Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.
Morse Trainer: the finished build
UnoStudent projects

Morse code trainer that decodes your keying (Arduino Uno + OLED)

Tap dots and dashes — the screen reads them back and scores you.

Difficulty
Easy
Build time
1 h
Parts cost
₹775 · $9
Verified — core avr 1.8.8, Adafruit SSD1306 2.5.17, 3 Nov 2026Compile-only

Community draft — report a mistake

Parts list

PartQtyApprox.Buy
Arduino Uno R3 (CH340 clone)1₹450Buy Arduino Uno R3 (CH340 clone)
Big push button or a home-made key (hacksaw blade + screw contact)1₹40Buy Big push button or a home-made key (hacksaw blade + screw contact)
Active buzzer module1₹25Buy Active buzzer module
0.96" SSD1306 OLED, I2C, 4-pin1₹180Buy 0.96" SSD1306 OLED, I2C, 4-pin
Jumper wires + breadboard1₹80Buy Jumper wires + breadboard
Total₹775

Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.

Wiring

Arduino Uno R3Only the pins used are shown · 5 V logicPush button (6 mm tactile switch)Active buzzer module (high-level trigger)SSD1306 OLED 0.96″ (128×64, I2C)5VGNDA4A5D2D812I/OVCCGNDGNDVCCSCLSDA
Every connection
Arduino Uno R3Part · pinWireNotes
D2Push button (6 mm tactile switch) · 1orangeThe Morse key — internal pull-up
GNDPush button (6 mm tactile switch) · 2black
D8Active buzzer module (high-level trigger) · I/OpurpleSide tone while the key is down
5VActive buzzer module (high-level trigger) · VCCred
GNDActive buzzer module (high-level trigger) · GNDblack
A4SSD1306 OLED 0.96″ (128×64, I2C) · SDAblue
A5SSD1306 OLED 0.96″ (128×64, I2C) · SCLyellow
5VSSD1306 OLED 0.96″ (128×64, I2C) · VCCred
GNDSSD1306 OLED 0.96″ (128×64, I2C) · GNDblack

Board quirks: A dot is one unit, a dash three units, the gap inside a letter one unit, between letters three, between words seven. The sketch measures your own dots and dashes and keeps adjusting the unit, so it follows you whether you key at 5 or 20 words per minute. Words per minute = 1200 ÷ dot length in ms — the standard “PARIS” timing used by radio amateurs. Holding the key for 2 seconds switches between TRAIN and FREE and resets the speed to 10 WPM. An active buzzer gives a fixed tone (usually around 2–3 kHz), which is shrill. For a softer 600–800 Hz tone, swap in a passive buzzer and use tone(8, 700) / noTone(8).

Code

morse-code-trainer.inoArduino C++Open in PlaygroundOpen in PlaygroundFlash
// Morse code trainer — Arduino Uno + one push button (the key) + active buzzer module + SSD1306 OLED.
// Key in Morse and the OLED decodes it. The dot length adapts to your speed (shown as WPM).
// Two modes, switched by holding the key for 2 s:
//   TRAIN — a random letter or digit appears; key it, and it says right or wrong (with the answer).
//   FREE  — everything you key is decoded into text on the screen.
// Key D2 → GND (internal pull-up), buzzer module I/O → D8, OLED SDA → A4, SCL → A5 (0x3C).
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

constexpr uint8_t KEY_PIN = 2, BUZZER_PIN = 8;
constexpr unsigned int MODE_HOLD_MS = 2000;

// Letter followed by its pattern, space-separated. Kept in flash to save RAM.
const char MORSE[] PROGMEM =
    "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----. ";

Adafruit_SSD1306 oled(128, 64, &Wire, -1);
bool train = true;
char target = 'E';
char pattern[8] = "";  // what is being keyed now, e.g. ".-"
uint8_t patLen = 0;
char text[22] = "";    // decoded text in FREE mode (one OLED line)
char verdict[22] = "Key the letter";
unsigned int unit = 120;  // dot length in ms (120 ms ≈ 10 words per minute)
unsigned int score = 0, tries = 0;
bool keyDown = false, spaced = true, modeSwitched = false;
unsigned long edgeAt = 0;

// Find the letter for a pattern (or the pattern for a letter): returns 0 / false if not found.
char decode(const char *pat) {
  for (const char *p = MORSE; pgm_read_byte(p); p++) {
    char letter = pgm_read_byte(p++);
    uint8_t i = 0;
    while (pgm_read_byte(p) != ' ' && pat[i] == (char)pgm_read_byte(p)) p++, i++;
    if (pgm_read_byte(p) == ' ' && pat[i] == 0) return letter;
    while (pgm_read_byte(p) != ' ') p++;
  }
  return 0;
}

void patternOf(char letter, char *out) {
  for (const char *p = MORSE; pgm_read_byte(p); p++) {
    char l = pgm_read_byte(p++);
    uint8_t i = 0;
    while (pgm_read_byte(p) != ' ') {
      if (l == letter) out[i++] = pgm_read_byte(p);
      p++;
    }
    if (l == letter) {
      out[i] = 0;
      return;
    }
  }
  out[0] = 0;
}

void newTarget() {
  const char set[] = "ETIANMSURWDKGOHVFLPJBXCYZQ0123456789";
  target = set[random(36)];
}

void draw() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.print(train ? F("TRAIN") : F("FREE"));
  oled.print(F("  "));
  oled.print(1200 / unit);
  oled.print(F(" WPM"));
  if (train) {
    oled.print(F("  "));
    oled.print(score);
    oled.print('/');
    oled.print(tries);
    oled.setTextSize(3);
    oled.setCursor(0, 14);
    oled.print(target);
    oled.setTextSize(1);
    oled.setCursor(30, 18);
    oled.print(verdict);
  } else {
    oled.setCursor(0, 16);
    oled.print(text);
  }
  oled.setTextSize(2);
  oled.setCursor(0, 46);
  oled.print(pattern);
  oled.display();
}

void letterDone() {
  if (!patLen) return;
  char c = decode(pattern);
  Serial.print(pattern);
  Serial.print(F(" = "));
  Serial.println(c ? c : '?');
  if (train) {
    tries++;
    if (c == target) {
      score++;
      strcpy_P(verdict, PSTR("Right!"));
    } else {
      char want[8];
      patternOf(target, want);
      snprintf_P(verdict, sizeof(verdict), PSTR("No: %c is %s"), target, want);
    }
    newTarget();
  } else {
    uint8_t n = strlen(text);
    if (n >= sizeof(text) - 1) memmove(text, text + 1, n--);  // scroll left
    text[n] = c ? c : '?';
    text[n + 1] = 0;
  }
  patLen = 0;
  pattern[0] = 0;
  draw();
}

void setup() {
  Serial.begin(115200);
  Serial.println(F("Morse trainer ready. Key on D2; hold 2 s to switch TRAIN / FREE."));
  pinMode(KEY_PIN, INPUT_PULLUP);
  pinMode(BUZZER_PIN, OUTPUT);
  if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println(F("OLED not found at 0x3C"));
  randomSeed(analogRead(A0));
  newTarget();
  draw();
}

void loop() {
  unsigned long now = millis();
  bool down = digitalRead(KEY_PIN) == LOW;

  if (down && !keyDown && now - edgeAt > 15) {  // key pressed (15 ms debounce)
    keyDown = true;
    edgeAt = now;
    digitalWrite(BUZZER_PIN, HIGH);  // side tone while the key is down
  } else if (!down && keyDown && now - edgeAt > 15) {  // key released
    keyDown = false;
    digitalWrite(BUZZER_PIN, LOW);
    unsigned int len = now - edgeAt;
    edgeAt = now;
    if (modeSwitched) {
      modeSwitched = false;
      return;
    }
    bool dash = len > 2 * unit;  // the dot/dash boundary sits at 2 units
    // Adapt: a dot is one unit, a dash three. Move a quarter of the way to the new estimate.
    unsigned int est = dash ? len / 3 : len;
    unit = constrain((unit * 3 + est) / 4, 40, 400);
    if (patLen < sizeof(pattern) - 1) {
      pattern[patLen++] = dash ? '-' : '.';
      pattern[patLen] = 0;
    }
    spaced = false;
    draw();
  }

  if (keyDown && !modeSwitched && now - edgeAt > MODE_HOLD_MS) {
    modeSwitched = true;
    digitalWrite(BUZZER_PIN, LOW);
    train = !train;
    patLen = 0;
    pattern[0] = 0;
    unit = 120;
    Serial.println(train ? F("Mode: TRAIN") : F("Mode: FREE"));
    draw();
  }

  if (!keyDown) {
    unsigned long gap = now - edgeAt;
    if (patLen && gap > 3UL * unit) letterDone();  // 3 units of silence ends a letter
    if (!train && !spaced && gap > 7UL * unit) {   // 7 units ends a word
      spaced = true;
      uint8_t n = strlen(text);
      if (n && n < sizeof(text) - 1) text[n] = ' ', text[n + 1] = 0;
      draw();
    }
  }
}

Compiled for uno on avr 1.8.8 with Adafruit SSD1306 2.5.17 · 3 Nov 2026

How it works

Morse code turns letters into short and long signals — dots and dashes. It was the first digital communication, sent over telegraph wires across India from the 1850s, and radio amateurs and the Navy still learn it today. This trainer turns one push button into a Morse key and an Uno into the listener that decodes it.

How it works

The sketch measures how long the key is held. Anything shorter than two units is a dot, longer is a dash. The unit starts at 120 ms (10 words per minute) and adapts: after every symbol it moves a quarter of the way towards the dot length you actually keyed (a dash counts as three units). That is why a beginner tapping slowly and a fast operator both get decoded correctly.

Silence is part of Morse too. When the key has been up for three units, the dots and dashes so far form one letter; the sketch looks the pattern up in a table of A–Z and 0–9 stored in flash memory. In FREE mode, seven units of silence add a space between words.

TRAIN mode shows a random letter or digit in large type. Key it: the screen says Right! or shows the correct pattern, e.g. “No: K is -.-”, and keeps a score. The pattern you are keying appears at the bottom as you go, and the buzzer gives a side tone while the key is pressed — like a real transmitter.

What to show at the exhibition

  • Challenge visitors to key SOS (· · · – – – · · ·) — almost everyone knows it — then their name.
  • Show a printed Morse chart and explain why E is one dot and T one dash: Samuel Morse’s team gave the shortest codes to the most common English letters.
  • Compare it with modern data: Morse is a variable-length code, an early form of the compression used in ZIP files.

Questions the examiner may ask

  • How does the Arduino tell a dot from a dash? It times the press with millis() and compares it with two dot-lengths.
  • Why does it need gaps? Without timing gaps, “· ·” (E E) and “· ·” (I) would look the same; the length of the silence separates symbols, letters and words.
  • What is WPM? Words per minute, based on the word PARIS, which is exactly 50 units long; at 10 WPM a dot is 120 ms.
  • Where are the codes stored? In flash memory with PROGMEM, because the Uno has only 2 KB of RAM and half of it is the OLED’s screen buffer.

Cost in India

About ₹780. A plain push button works; a key made from a hacksaw blade screwed to a block of wood, touching a screw head, feels much more like the real thing and costs almost nothing.

Troubleshooting

Dashes are read as dots

Your dashes are shorter than twice your dots. Make dashes clearly longer — about three dots — and the adaptive timing settles after a few letters.

Letters run together

Leave a pause of about three dots between letters. At the start the unit is 120 ms, so the pause is just over a third of a second.

Random letters appear

The key is bouncing or the wire is long. The sketch ignores edges closer than 15 ms; for a scratchy home-made key add a 100 nF capacitor across it.

Upgrades

  • Add a second Uno and a long wire (or nRF24L01 radios) to send messages between two benches.
  • Show the Koch method: start with two letters and add one each time you score 90 %.
  • Flash an LED as well as beeping, to show how ships signalled with lamps.

↑↓ to move · Enter to open