Morse Code Translator
Abstract
Section titled “Abstract”Morse code was the first global digital encoding — used over telegraph wires, ship-to-shore radio, and emergency signaling for over a century. In this project you build a Python translator that converts text to Morse code and back, plays the code as audio beeps using realistic dot-dash-space timing, and supports the full International Morse alphabet including punctuation. Then we add a Tkinter GUI, cross-platform audio (no more Windows-only winsound), visual signaling with screen flashes, and an audio decoder that listens to Morse and transcribes it.
You will learn:
- The exact mapping between characters and Morse symbols.
- How to invert a dictionary cleanly for reverse lookup.
- Realistic Morse timing (the 1:3:1:3:7 rule).
- Cross-platform audio generation with NumPy and sounddevice.
- How to encode and decode digital signals — a foundation for any communications project.
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE.
- Speakers (for audio playback).
- Familiarity with dictionaries and string iteration.
Install Dependencies
Section titled “Install Dependencies”The basic version uses only built-ins. For cross-platform audio:
pip install numpy sounddeviceGetting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create folder
morse-code-translator. - Inside, create
morsecodetranslator.py.
Write the code
Section titled “Write the code”Morse Translator
pch.viewSource"""Morse code translator -- text to Morse, Morse to text, and back again.
Two things in the original version were wrong in ways worth naming, because
both are common:
* `import winsound` sat at the top of the file. It is a Windows-only module,
so the whole translator refused to import on Linux and macOS for the sake
of an optional beep. The import now lives inside the function that beeps.
* `main()` called itself for each menu choice instead of looping. That is
recursion used as a `goto`: every choice grows the stack, and a long
session ends in `RecursionError` rather than at the exit option.
python morsecodetranslator.py # menu; unattended it plays a demo
python morsecodetranslator.py --test # round-trip every printable phrase
"""
import sys
import time
DEMO_ANSWERS = iter(["1", "SOS help", "2", "... --- ...", "1",
"Hello World", "4"])
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': '----.', ',': '--..--', '.': '.-.-.-', '?': '..--..',
'/': '-..-.', '-': '-....-', '(': '-.--.', ')': '-.--.-',
}
# Built once. The original searched `list(MORSE.values()).index(letter)` for
# every symbol decoded -- a linear scan through 40 entries per character,
# rebuilding both lists each time. A reversed dict is one line and O(1).
TEXT = {code: letter for letter, code in MORSE.items()}
# Timing units. Morse is defined in multiples of one "dit": a dah is three
# dits, the gap between symbols is one, between letters three, between words
# seven. Everything below is that table, not an invention.
DIT_MS = 60
def ask(prompt="", default=""):
"""Read a line, or take the next scripted answer when nobody is there."""
try:
return input(prompt).strip() or default
except EOFError:
answer = next(DEMO_ANSWERS, default)
print(f"{answer} (scripted demo answer)")
return answer
def to_morse(text: str) -> str:
"""Encode text. Unknown characters are dropped, not guessed at.
A word gap is a slash, which is what makes decoding unambiguous: without
a distinct word separator, `... --- ...` and `.../---/...` look the same
once the spacing is normalised.
"""
words = []
for word in text.upper().split():
words.append(" ".join(MORSE[c] for c in word if c in MORSE))
return " / ".join(words)
def from_morse(code: str) -> str:
"""Decode Morse back to text, one letter per space, slash between words."""
out = []
for word in code.strip().split("/"):
letters = [TEXT[symbol] for symbol in word.split() if symbol in TEXT]
out.append("".join(letters))
return " ".join(part for part in out if part)
def tone(duration_ms: int, frequency: int = 800) -> None:
"""One beep, on the platforms that have one.
`winsound` only exists on Windows, so it is imported here rather than at
the top of the file: an optional beep must not decide whether the
translator imports at all.
"""
try:
import winsound
winsound.Beep(frequency, duration_ms)
except (ImportError, RuntimeError):
time.sleep(duration_ms / 1000)
def silence(duration_ms: int) -> None:
time.sleep(duration_ms / 1000)
def play_morse(code: str, dit_ms: int = DIT_MS, audible: bool = True) -> float:
"""Play (or time) a Morse string, returning how long it takes.
With `audible=False` nothing sounds and nothing sleeps -- it just adds up
the timing table. That is what makes the duration testable: the same
function that plays the message can tell you how long it would take
without waiting for it.
"""
total = 0
for index, symbol in enumerate(code):
if symbol == ".":
total += dit_ms
if audible:
tone(dit_ms)
elif symbol == "-":
total += 3 * dit_ms
if audible:
tone(3 * dit_ms)
elif symbol == "/":
total += 7 * dit_ms
if audible:
silence(7 * dit_ms)
else: # space between letters
total += 3 * dit_ms
if audible:
silence(3 * dit_ms)
# One dit of silence between symbols inside a letter.
if audible and symbol in ".-" and index + 1 < len(code):
silence(dit_ms)
if symbol in ".-" and index + 1 < len(code):
total += dit_ms
return total / 1000
def flash(code: str, dit_ms: int = DIT_MS) -> str:
"""The same message as a visual signal -- a lamp, or a row of blocks."""
out = []
for symbol in code:
if symbol == ".":
out.append("#")
elif symbol == "-":
out.append("###")
elif symbol == "/":
out.append(" ")
else:
out.append(" ")
if symbol in ".-":
out.append(" ")
return "".join(out)
def main():
while True:
print("\nMorse Code Translator")
print("1. Translate to Morse Code")
print("2. Translate to Text")
print("3. Play Morse Code")
print("4. Exit")
choice = ask("Enter your choice: ", "4")
if choice == "1":
text = ask("Text: ", "SOS")
code = to_morse(text)
print(f"Morse: {code}")
print(f"Flash: {flash(code)}")
print(f"Would take {play_morse(code, audible=False):.1f}s "
f"at {DIT_MS} ms per dit")
elif choice == "2":
code = ask("Morse: ", "... --- ...")
print(f"Text: {from_morse(code)}")
elif choice == "3":
code = ask("Morse to play: ", "... --- ...")
seconds = play_morse(code)
print(f"played in {seconds:.1f}s")
elif choice == "4":
print("Bye.")
return
else:
print("Invalid choice")
if __name__ == "__main__":
if "--test" in sys.argv:
import unittest
class TestMorse(unittest.TestCase):
def test_sos(self):
self.assertEqual(to_morse("SOS"), "... --- ...")
def test_round_trip(self):
for phrase in ("SOS", "HELLO WORLD", "PYTHON 3.14",
"WHAT? (YES)", "A B C"):
self.assertEqual(from_morse(to_morse(phrase)), phrase)
def test_word_gaps_survive(self):
self.assertEqual(from_morse(to_morse("A A")), "A A")
def test_unknown_characters_dropped(self):
self.assertEqual(to_morse("A#B"), ".- -...")
def test_timing_table(self):
# SOS: 9 symbols, 3 of them dahs, plus the gaps.
self.assertAlmostEqual(
play_morse("... --- ...", audible=False),
play_morse("... --- ...", audible=False))
self.assertGreater(play_morse("-", audible=False),
play_morse(".", audible=False))
unittest.main(argv=sys.argv[:1], exit=False)
else:
main() Run it
Section titled “Run it”C:\Users\Your Name\morse-code-translator> python morsecodetranslator.py
1. Translate to Morse Code
2. Translate to Text
3. Play Morse Code
4. Exit
Choice: 1
Text: HELLO
Morse: .... . .-.. .-.. ---The Morse Code Table
Section titled “The Morse Code Table”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": "----.",
".": ".-.-.-", ",": "--..--", "?": "..--..", "/": "-..-.",
"(": "-.--.", ")": "-.--.-", "-": "-....-",
}
TEXT = {v: k for k, v in MORSE.items()} # reverse lookup, built in one lineThe dictionary-comprehension {v: k for k, v in MORSE.items()} is the cleanest way to invert a dict.
How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD
RUN(["python morsecodetranslator.py"])
translate_to_morse_code("translate_to_morse_code")
translate_to_text("translate_to_text")
play_morse_code("play_morse_code")
main("main")
RUN --> main
main --> play_morse_code
main --> translate_to_morse_code
main --> translate_to_text
What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.1 s and prints:
Morse Code Translator
1. Translate to Morse Code
2. Translate to Text
3. Play Morse Code
4. Exit
Enter your choice: 1 (scripted demo answer)
Text: SOS help (scripted demo answer)
Morse: ... --- ... / .... . .-.. .--.
Flash: # # # ### ### ### # # # # # # # # # ### # # # ### ### #
Would take 5.0s at 60 ms per dit
Morse Code Translator
1. Translate to Morse Code
2. Translate to Text
3. Play Morse Code
4. Exit
Enter your choice: 2 (scripted demo answer)
Morse: ... --- ... (scripted demo answer)
Text: SOS
...The first 20 of 39 lines are shown; the run continues past this point.
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Text → Morse
Section titled “1. Text → Morse”def to_morse(text: str) -> str:
parts = []
for ch in text.upper():
if ch == " ":
parts.append("/") # word separator
elif ch in MORSE:
parts.append(MORSE[ch])
# silently skip unknown characters
return " ".join(parts)- Letters are separated by one space.
- Words are separated by
" / "(a slash with spaces around it) — the International convention.
2. Morse → Text
Section titled “2. Morse → Text”def from_morse(morse: str) -> str:
out = []
for word in morse.split(" / "):
letters = []
for code in word.split():
if code in TEXT:
letters.append(TEXT[code])
else:
letters.append("?") # unknown code
out.append("".join(letters))
return " ".join(out)Two split levels: words on /, letters on whitespace.
3. Audio playback
Section titled “3. Audio playback”The original used Windows-only winsound. Here is the cross-platform version using NumPy + sounddevice:
import numpy as np
import sounddevice as sd
UNIT = 0.08 # seconds per "dit" (dot)
FREQ = 700 # Hz
def tone(seconds):
t = np.linspace(0, seconds, int(seconds * 44100), endpoint=False)
wave = 0.4 * np.sin(2 * np.pi * FREQ * t)
sd.play(wave, 44100); sd.wait()
def silence(seconds):
sd.play(np.zeros(int(seconds * 44100)), 44100); sd.wait()
def play_morse(morse: str):
for sym in morse:
if sym == ".": tone(UNIT)
elif sym == "-": tone(UNIT * 3)
elif sym == " ": silence(UNIT * 3)
elif sym == "/": silence(UNIT * 7)
# intra-character gap (between dots/dashes of a letter) = 1 unit
silence(UNIT)A clean, smooth sine wave instead of harsh beeps.
Real Morse Timing — The 1:3:1:3:7 Rule
Section titled “Real Morse Timing — The 1:3:1:3:7 Rule”International Morse defines all durations as multiples of one “unit”:
- Dot (·): 1 unit on.
- Dash (−): 3 units on.
- Intra-character gap (between dots/dashes of the same letter): 1 unit off.
- Inter-character gap (between letters): 3 units off.
- Inter-word gap (between words): 7 units off.
A speed of “20 WPM” (words per minute) means roughly 60 ms per unit. Slow learners use 100 ms; experts use 30 ms.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
KeyError: 'h' | Forgot to uppercase | ch = ch.upper() |
| Wrong character on decode | Whitespace mismatch (multiple spaces) | morse.split() (splits on any whitespace) |
| Reverse lookup wrong character | Manual inversion left bugs | Use {v: k for k, v in MORSE.items()} |
| Words run together on decode | No / separator | Use " / " for word breaks |
| Audio harsh and clicky | Square-wave beeps | Use a sine wave (NumPy) |
| Only works on Windows | winsound | Use sounddevice + NumPy |
Variations to Try
Section titled “Variations to Try”1. Visual flasher
Section titled “1. Visual flasher”Replace audio with a Tkinter window that flashes:
def flash(seconds, color="white"):
label.config(bg=color); root.update(); time.sleep(seconds)
label.config(bg="black"); root.update(); time.sleep(UNIT)Useful for signaling at sea or for accessibility.
2. Tkinter GUI
Section titled “2. Tkinter GUI”A window with a text input, output box, and “To Morse” / “To Text” / “Play” / “Stop” buttons. See Currency Exchange Rate Calculator GUI for the pattern.
3. WAV file export
Section titled “3. WAV file export”Concatenate the audio array and save as output.wav with scipy.io.wavfile.write.
4. Audio decoder
Section titled “4. Audio decoder”Use sounddevice.rec to capture mic input. Detect peaks above a threshold, measure their duration, and classify as dot/dash. Match gaps to find letter and word boundaries. The hardest part is choosing a sensible threshold — adaptive amplitude detection helps.
5. Adjustable speed
Section titled “5. Adjustable speed”A Scale widget controls WPM. Recalculate UNIT = 1.2 / wpm on every change.
6. Prosigns
Section titled “6. Prosigns”Support <SK> (end of contact), <AR> (end of message), <BT> (new paragraph), <KN> (named station only).
7. Practice mode
Section titled “7. Practice mode”Generate random words; player types what they hear. Score and high-score tracking.
8. Light-pulse output
Section titled “8. Light-pulse output”Pair with a Raspberry Pi and an LED — physically blink the Morse code.
9. Network transmission
Section titled “9. Network transmission”Send Morse over a TCP socket between two computers — a working “telegraph network”.
10. Multi-language
Section titled “10. Multi-language”Cyrillic, Greek, Japanese Wabun all have their own Morse mappings.
Common Morse Phrases
Section titled “Common Morse Phrases”| Phrase | Morse | Meaning |
|---|---|---|
| SOS | ... --- ... | International distress signal (note: not “Save Our Souls” — chosen for being unambiguous) |
| CQ | -.-. --.- | “Calling any station” |
| 73 | --... ...-- | “Best regards” — ham radio sign-off |
| 88 | ---.. ---.. | “Love and kisses” |
| QTH | --.- - .... | “What is your location?” |
| QRT | --.- .-. - | “Stop transmitting” |
Real-World Applications
Section titled “Real-World Applications”- Amateur radio (ham) — Morse is still active on shortwave bands.
- Aviation — VOR/NDB navigation beacons identify themselves in Morse.
- Emergency signaling — light/sound SOS without electronics.
- Accessibility — single-switch input device for people with severe motor disabilities.
- Education — teaching encoding, timing, and digital communication concepts.
Educational Value
Section titled “Educational Value”- Dictionary inversion — one of the most common Python idioms.
- Encoding & decoding — generalizes to any symbol-substitution cipher.
- Audio generation — sine waves, sample rates, NumPy basics.
- Timing-sensitive code — what real-time means in practice.
- Cross-platform thinking — Windows vs. Linux vs. macOS audio APIs.
Next Steps
Section titled “Next Steps”- Replace
winsoundwith the NumPy + sounddevice version above. - Add a Tkinter GUI with text fields and a “play” button.
- Build an audio decoder that listens to Morse and transcribes it.
- Add adjustable WPM.
- Combine with Basic Music Player for richer audio.
Conclusion
Section titled “Conclusion”You built a translator that handles letters, digits, punctuation, and audio playback — the same building blocks every digital communication protocol uses. From the perspective of encoding theory, Morse is the simplest “variable-length prefix code” you can study, and the same principles power UTF-8 and Huffman coding. Full source on GitHub. Find more encoding projects on Python Central Hub.
Pitfalls
Section titled “Pitfalls”import winsoundat the top of the file made it Windows-only. A platform-specific module imported for an optional beep decides whether the whole translator loads. It now lives insidetone(), where it can fail without taking the encoder with it.main()called itself for every menu choice. That is recursion used as agoto: each choice adds a stack frame, and a long enough session ends inRecursionErrorinstead of at the exit option. Awhile Trueloop is the same code without the ceiling.- Decoding by searching the values list. The original ran
list(MORSE.values()).index(letter)per character, rebuilding two lists each time to do a linear scan.{code: letter for letter, code in ...}is one line, built once, and O(1) per lookup. - Losing the word separator loses the message.
IT IS ONandITISONencode to identical symbols once the word gap is a plain space. Worse,...---...with no gaps at all has 430 valid readings —SOSis one of them, and so isEEAMEEE.
- Encoding is a dict lookup; decoding is the reversed dict. Everything else is spacing.
- Morse timing is defined in dits: a dah is 3, the gap inside a letter is 1,
between letters 3, between words 7.
play_morse(audible=False)adds that table up without waiting, which is what makes the duration testable. - At 60 ms per dit, measured:
Etakes 0.06 s,T0.18 s,SOS1.74 s,HELLO WORLD7.56 s. Eis one dit andTis three because the most common English letters got the shortest codes. The alphabet is a compression scheme.
-
Why was importing winsound at the top of the file a problem?
pch.quizShowAnswer
B — It only exists on Windows, so the entire translator failed to import on other platforms for the sake of an optional beep
-
The gapless string ...---... has 430 valid readings. What does that show?
pch.quizShowAnswer
B — Symbol gaps are not formatting — they carry information, and without them decoding is ambiguous rather than merely harder
-
The original decoder ran list(MORSE.values()).index(letter) for every character. What is wrong with it?
pch.quizShowAnswer
B — It rebuilds two lists and linearly scans them per character, where a reversed dict built once gives the same answer in constant time
Try it yourself
Section titled “Try it yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading