Skip to content

Morse Code Translator

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.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Speakers (for audio playback).
  • Familiarity with dictionaries and string iteration.

The basic version uses only built-ins. For cross-platform audio:

install
pip install numpy sounddevice
  1. Create folder morse-code-translator.
  2. Inside, create morsecodetranslator.py.
Morse Translator pch.viewSource
Morse Translator
"""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()
command
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: .... . .-.. .-.. ---
codes.py
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 line

The dictionary-comprehension {v: k for k, v in MORSE.items()} is the cleanest way to invert a dict.

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.

diagram Diagram mermaid

Running the file exactly as it ships takes 0.1 s and prints:

python morsecodetranslator.py
 
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.

encode.py
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.
decode.py
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.

The original used Windows-only winsound. Here is the cross-platform version using NumPy + sounddevice:

play.py
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.

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.

ProblemCauseFix
KeyError: 'h'Forgot to uppercasech = ch.upper()
Wrong character on decodeWhitespace mismatch (multiple spaces)morse.split() (splits on any whitespace)
Reverse lookup wrong characterManual inversion left bugsUse {v: k for k, v in MORSE.items()}
Words run together on decodeNo / separatorUse " / " for word breaks
Audio harsh and clickySquare-wave beepsUse a sine wave (NumPy)
Only works on WindowswinsoundUse sounddevice + NumPy

Replace audio with a Tkinter window that flashes:

flash.py
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.

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.

Concatenate the audio array and save as output.wav with scipy.io.wavfile.write.

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.

A Scale widget controls WPM. Recalculate UNIT = 1.2 / wpm on every change.

Support <SK> (end of contact), <AR> (end of message), <BT> (new paragraph), <KN> (named station only).

Generate random words; player types what they hear. Score and high-score tracking.

Pair with a Raspberry Pi and an LED — physically blink the Morse code.

Send Morse over a TCP socket between two computers — a working “telegraph network”.

Cyrillic, Greek, Japanese Wabun all have their own Morse mappings.

PhraseMorseMeaning
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”
  • 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.
  • 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.
  • Replace winsound with 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.

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.

  • import winsound at 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 inside tone(), where it can fail without taking the encoder with it.
  • main() called itself for every menu choice. That is recursion used as a goto: each choice adds a stack frame, and a long enough session ends in RecursionError instead of at the exit option. A while True loop 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 ON and ITISON encode to identical symbols once the word gap is a plain space. Worse, ...---... with no gaps at all has 430 valid readings — SOS is one of them, and so is EEAMEEE.
  • 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: E takes 0.06 s, T 0.18 s, SOS 1.74 s, HELLO WORLD 7.56 s.
  • E is one dit and T is three because the most common English letters got the shortest codes. The alphabet is a compression scheme.
pch.quizTag pch.quizDefaultTitle
  1. 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

  2. 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

  3. 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

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading