Morse Code on a Raspberry Pi Pico: Build a Hardware Keyer with MicroPython
The Raspberry Pi Pico costs about the price of a coffee, runs Python, and has more than enough GPIO to key a transmitter, drive a buzzer, and decode what you send — all at the same time. If you have ever wanted a Morse practice station that is a real piece of hardware rather than a browser tab, this is the cheapest honest way to build one. In this article we will wire a straight key (or a push button) to a Pico, write a small MicroPython keyer, and end up with a device that beeps, flashes, and prints the text you keyed to your computer over USB.
No radio licence is needed for any of this: nothing here transmits. It is a practice keyer, a decoder, and a surprisingly good way to learn how Morse timing actually works.
What you need
- A Raspberry Pi Pico (any Pico, including Pico W) with MicroPython installed
- A momentary push button or a real straight key — a button is fine for learning
- A passive buzzer (or a small speaker) and a 220 ohm resistor
- A breadboard and a few jumper wires
- A USB cable and Thonny (or any serial terminal)
Total cost, excluding the Pico, is under two dollars. The wiring is deliberately minimal: one input pin for the key, one output pin for the buzzer, and the Pico's built-in LED as a visual sidetone.
Wiring it up
The circuit has three connections:
- Key input — GP14. One side of the button goes to GP14, the other side to ground. We will enable the Pico's internal pull-up resistor, so the pin reads high when the key is up and low when it is pressed.
- Buzzer — GP15. The buzzer's positive lead goes to GP15 through a 220 ohm resistor, the negative lead to ground. We drive it with PWM so we can pick a pleasant sidetone frequency.
- Visual sidetone — GP25. This is the Pico's on-board LED, already wired for us.
That is the whole circuit. If you are using a real straight key instead of a button, the wiring is identical: the key is just a switch that connects GP14 to ground when pressed.
The timing rules, in code
Morse timing is defined in units. At a given speed in words per minute (WPM), one unit lasts 1200 / WPM milliseconds — the PARIS standard, the same rule every decoder uses. A dit is 1 unit, a dah is 3 units, the gap inside a character is 1 unit, the gap between characters is 3 units, and the gap between words is 7 units.
At 15 WPM, one unit is 80 ms. That is fast enough to feel musical and slow enough to learn on, so we will use 15 WPM as the default. The decoder classifies a mark as a dit when it is shorter than 2 units and a dah when it is longer — the midpoint rule, which is forgiving of human unevenness.
Here is the full keyer. Save it as main.py on the Pico so it runs on power-up:
from machine import Pin, PWM import time WPM = 15 UNIT = 1200 // WPM # milliseconds per dit at this speed key = Pin(14, Pin.IN, Pin.PULL_UP) led = Pin(25, Pin.OUT) buzzer = PWM(Pin(15)) buzzer.freq(600) # a comfortable sidetone pitch buzzer.duty_u16(0) 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', } def sidetone(on): buzzer.duty_u16(20000 if on else 0) led.value(1 if on else 0) def decode(symbols): return MORSE.get(symbols, '?') def main(): print("Pico keyer ready. Key at", WPM, "WPM.") symbols = '' while True: # Wait for the key to go down while key.value() == 1: time.sleep_ms(5) mark_start = time.ticks_ms() sidetone(True) # Wait for the key to come up while key.value() == 0: time.sleep_ms(5) mark_ms = time.ticks_diff(time.ticks_ms(), mark_start) sidetone(False) symbols += '.' if mark_ms < 2 * UNIT else '-' # Measure the gap after the mark gap_start = time.ticks_ms() while key.value() == 1: gap_ms = time.ticks_diff(time.ticks_ms(), gap_start) if gap_ms >= 7 * UNIT: # Word gap: flush the character, then a space print(decode(symbols), end='') print(' ', end='') symbols = '' break if gap_ms >= 3 * UNIT: # Character gap: flush the character print(decode(symbols), end='') symbols = '' break time.sleep_ms(5) main() Key what you like in Thonny's serial monitor and the decoded text appears as you send it. The two thresholds do all the work: marks are classified the moment the key comes up, and gaps are classified while you wait for the next press.
Why polling works — and when to switch to interrupts
The loop above polls the key every 5 ms. At 15 WPM a dit lasts 80 ms, so polling catches every press with at least 16 samples to spare, and the timing error is bounded by the 5 ms sleep — small enough that nobody's sending is that precise anyway. Polling is the right choice for a first version because the whole program fits in one readable loop.
If you later want to send faster, or add a second paddle for iambic keying, switch the key input to an interrupt:
def on_key_change(pin): now = time.ticks_ms() if pin.value() == 0: # key down: record the time, start the sidetone pass else: # key up: classify the mark from the elapsed time pass key.irq(trigger=Pin.IRQ_FALLING | Pin.IRQ_RISING, handler=on_key_change) Interrupts free the main loop to do other work — updating a display, logging to a file, or driving a second buzzer — while the timing stays exact. But do not start there. Get the polling version working first, then refactor; a keyer you can hear is worth more than an elegant one you have not finished.
Adding a practice mode
A keyer that only echoes what you send is half a practice tool. The other half is a trainer that sends to you. Add a simple practice mode that plays a random character every few seconds and waits for you to key it back:
import random def play(symbols, unit=UNIT): for s in symbols: sidetone(True) time.sleep_ms(unit if s == '.' else 3 * unit) sidetone(False) time.sleep_ms(unit) def practice_round(): ch = random.choice(list(MORSE.values())) code = [k for k, v in MORSE.items() if v == ch][0] print("Listen...", end=' ') play(code) print("now key it back!") Call practice_round() in a loop and you have a trainer that costs nothing extra: the same buzzer, the same LED, the same key. Start with the letters E, T, I, M, A and N — the six shortest codes — and add a new letter only when you can copy the current set without thinking. This is the Koch method in miniature, and it works because it trains your ear before your memory.
Five mistakes that make a Pico keyer feel broken
- No pull-up resistor. If GP14 floats, the pin reads random noise and the keyer decodes phantom dits. The internal pull-up in the code above fixes this — but only if you actually pass
Pin.PULL_UP. - A buzzer with no resistor. A passive buzzer straight across a GPIO pin can draw more current than the pin likes. The 220 ohm resistor costs nothing and protects the Pico.
- Debounce done wrong. Mechanical keys bounce: one press looks like several short presses. The 5 ms polling loop smooths most of it, but if you see stray dits, add a 10–15 ms settle delay after each key-down edge before you start timing.
- Timing against the wrong clock. Use
time.ticks_ms()andtime.ticks_diff(), nevertime.time(). The ticks functions handle the millisecond counter wrapping around; naive subtraction breaks after the counter overflows. - A sidetone that is too loud or too quiet. PWM duty of 20000 out of 65535 is a good middle ground. If the buzzer screams, lower the duty; if you can barely hear it, raise it. The frequency matters too — 600 Hz is comfortable for long sessions, while 800 Hz cuts through room noise better.
What this teaches you that a browser app cannot
A browser trainer hides the timing from you. A Pico keyer makes it physical: you feel the 80 ms dit in your fingertip, you hear the gap between characters as silence, and you discover quickly that your dahs are shorter than you think they are. That feedback loop — send, hear, see the decoded text, correct — is the fastest way to build the timing sense that separates a readable sender from a sloppy one.
And the same code scales. Swap the button for a real key, swap the buzzer for an audio oscillator, add a small OLED display, and you have a field practice station that runs for days on a USB power bank. The decoder logic never changes: classify the mark, classify the gap, flush the character. Everything else is hardware you can add one piece at a time.
Frequently asked questions
Do I need a real Morse key, or is a button enough?
A button is enough to learn on. A straight key feels better and teaches proper wrist motion, but the electronics and the code are identical either way.
Can the Pico key a real transmitter?
Yes, with an interface circuit — never connect a transmitter directly to a GPIO pin. A transistor or optocoupler between GP15 and the radio's key input is the standard approach, and it keeps your Pico safe from RF and voltage spikes.
Why 15 WPM and not slower?
Slower speeds feel easier but teach bad habits: at 5 WPM people count dits and dahs instead of hearing whole characters. Fifteen WPM is slow enough to learn on and fast enough that characters arrive as single sounds.
Can I save my practice sessions?
Yes — open the Pico's filesystem in Thonny and append decoded text to a file, or log it over USB serial from your computer. A week's worth of sessions fits in a few kilobytes.
What if my decoded text is full of question marks?
Your marks are landing near the 2-unit boundary, so the classifier is guessing. Slow down slightly, or lengthen your dahs deliberately for a few sessions until the rhythm settles.
Morse Zone (morsezone.com) is a free online Morse code translator with audio playback and learning tools — a handy reference for checking your Pico keyer's decoded output against known-good text.
