Coding
Developer tutorial · pure standard library · tested code

Build a Morse Code Translator in Python

Featured image: Build a Morse Code Translator in Python (Full Tutorial)
.--. -.--
Encode, decode, generate real morse audio, and ship it as a CLI — in one file, zero dependencies.

What You'll Build

By the end of this tutorial you'll have a single Python file — morse_translator.py — that does four things:

  1. Encodes text to morse: HELLO WORLD → .... . .-.. .-.. --- / .-- --- .-. .-.. -..
  2. Decodes morse back to text, including the ...---... SOS prosign
  3. Writes real audio — a proper WAV file with correct ITU timing you can play anywhere
  4. Runs as a command-line tool with --decode, --wav and --wpm flags

Prerequisites: Python 3 installed, and a text editor. No packages to install — everything uses the standard library (argparse, wave, struct, math, re). Every block below was executed and tested before publishing; the full table was verified character-by-character against the canonical ITU mapping.

If you just need a translation right now without writing code, our web translator does it instantly. This tutorial is for people who want to understand — and own — the machinery.

1 The Code Table: One Source of Truth

Every morse program starts with the same thing: a dictionary mapping characters to dot-dash patterns. This is the ITU International Morse table — 26 letters, 10 digits, 18 punctuation marks. Get one pattern wrong and everything downstream is wrong, so this table is the foundation we verify, not the part we improvise.

# ITU International Morse code table (single source of truth)
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': '----.',
    '.': '.-.-.-', ',': '--..--', '?': '..--..', '!': '-.-.--',
    "'": '.----.', '"': '.-..-.', '/': '-..-.', '@': '.--.-.',
    '(': '-.--.',  ')': '-.--.-', '&': '.-...',  ':': '---...',
    ';': '-.-.-.', '=': '-...-',  '+': '.-.-.',  '-': '-....-',
    '_': '..--.-', '$': '...-..-',
}

Notice the design decision hidden in this table: it's character → code, which makes encoding a trivial lookup. Decoding needs the reverse, and rather than maintaining two tables that can drift apart, we generate the reverse programmatically:

# Reverse lookup: morse pattern -> character (generated, never hand-written)
REVERSE = {code: char for char, code in MORSE.items()}

This is a small habit that prevents an entire class of bugs: a single source of truth, derived views computed from it. If the ITU ever adds a character (it did — @ in 2004), you update one place.

2 Encoding: Text → Morse

Encoding is a pipeline: uppercase the input, split into words, map each character through the table, join letters with spaces and words with / . Characters with no morse equivalent (emoji, accented letters) are skipped rather than crashing the program — the same policy our web translator uses, where they're flagged instead.

def encode(text):
    """Encode text to morse code.

    Letters are separated by single spaces, words by ' / '.
    Characters with no morse equivalent are skipped.
    """
    words = []
    for word in text.upper().split():
        letters = [MORSE[ch] for ch in word if ch in MORSE]
        words.append(' '.join(letters))
    return ' / '.join(words)
>>> encode("HELLO WORLD")
'.... . .-.. .-.. --- / .-- --- .-. .-.. -..'

Why / between words instead of just extra spaces? Because morse is ambiguous without an explicit word separator — it's the written equivalent of the 7-unit silence between words in real timing. (Our timing guide covers the 1-3-7 spacing rule this mirrors.)

3 Decoding: Morse → Text

Decoding mirrors encoding: split on / for words, split on whitespace for letters, look each pattern up in REVERSE. Unknown patterns become ? — visible, not silent. Silent data corruption is the worst kind of bug; a visible ? tells the user exactly where the problem is.

def decode(morse):
    """Decode morse code back to text.

    Letters must be separated by spaces; words by ' / '.
    Unknown patterns become '?'.
    """
    words = []
    for word in morse.strip().split('/'):
        letters = [REVERSE.get(token, '?') for token in word.split()]
        words.append(''.join(letters))
    return ' '.join(words)
>>> decode(".... . .-.. .-.. --- / .-- --- .-. .-.. -..")
'HELLO WORLD'

A good sanity test for any codec: round-trip everything. Encode every supported character, decode the result, and demand the original back. If even one pattern is wrong in the table, this catches it:

full = ''.join(sorted(MORSE.keys()))
assert decode(encode(full)) == full  # passes: table is self-consistent

4 Prosigns: The Special Case

Here's a subtlety that separates a toy from a real translator. encode("SOS") gives ... --- ... — three letters with gaps. But the distress signal is ...---..., sent as one run-together group with no letter gaps. That's a prosign (procedural signal), and real morse software handles them explicitly.

We support the 7 standard prosigns, written in angle brackets — the same <SOS> convention our prosigns page documents:

# Prosigns: run-together groups sent with no letter gaps, written <LIKE THIS>.
PROSIGNS = {
    'SOS': '...---...', 'AR': '.-.-.', 'SK': '...-.-', 'BT': '-...-',
    'KN': '-.--.', 'AS': '.-...', 'HH': '........',
}

# ...---... only ever means SOS (unlike .-.-. which also means +),
# so it is safe to add as a decode shortcut.
REVERSE['...---...'] = 'SOS'


def encode(text):
    """Encode text to morse code.

    Prosigns can be sent as <SOS>, <AR> and so on -- they are
    transmitted as one run-together group with no letter gaps.
    """
    words = []
    for word in text.upper().split():
        letters = []
        for part in re.split(r'(<[A-Z]+>)', word):
            if not part:
                continue
            if part.startswith('<') and part[1:-1] in PROSIGNS:
                letters.append(PROSIGNS[part[1:-1]])
            else:
                letters.extend(MORSE[ch] for ch in part if ch in MORSE)
        words.append(' '.join(letters))
    return ' / '.join(words)
>>> encode("SOS")            # three letters
'... --- ...'
>>> encode("<SOS>")           # the prosign
'...---...'
>>> encode("DE <AR>")         # end of message
'-.. . / .-.-.'

Note the decode-side judgment call: most prosign patterns collide with punctuation (.-.-. is both <AR> and +), so only the unambiguous ...---... gets a decode shortcut. When standards collide, the printable character wins — same rule the pros use.

5 Audio: Morse You Can Hear

Text output is nice, but morse is sound. Using only the standard library's wave module, we can synthesize real morse audio with correct ITU timing: a dot is 1 unit, a dash is 3 units, with 1-unit gaps inside letters, 3 between letters, and 7 between words. One unit = 1.2 ÷ WPM seconds — the PARIS standard our timing guide explains.

def morse_to_wav(morse, filename, wpm=20, freq=700, sample_rate=44100):
    """Render a morse string to a 16-bit mono WAV file."""
    unit = 1.2 / wpm
    samples = []

    def tone(duration):
        count = int(duration * sample_rate)
        for i in range(count):
            # sine wave at freq Hz, 50% volume to leave headroom
            value = math.sin(2 * math.pi * freq * i / sample_rate)
            samples.append(int(16383 * value))

    def silence(duration):
        samples.extend([0] * int(duration * sample_rate))

    words = [w for w in morse.strip().split('/') if w.strip()]
    for wi, word in enumerate(words):
        codes = word.split()
        for ci, code in enumerate(codes):
            for si, symbol in enumerate(code):
                tone(unit * 3 if symbol == '-' else unit)
                if si < len(code) - 1:
                    silence(unit)          # gap inside a letter
            if ci < len(codes) - 1:
                silence(3 * unit)          # gap between letters
        if wi < len(words) - 1:
            silence(7 * unit)              # gap between words

    frames = struct.pack('<' + 'h' * len(samples), *samples)
    with wave.open(filename, 'wb') as wav_file:
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(sample_rate)
        wav_file.writeframes(frames)

How it works, piece by piece: tone() generates a 700 Hz sine wave (the traditional CW pitch) sample by sample; silence() appends zeros for the gaps; the nested loops walk the morse string applying the 1-3-7 spacing. struct.pack converts the Python integers to little-endian 16-bit samples, and wave writes the proper RIFF header so any player opens it.

One honest limitation: wave writes audio files — it can't play them. That's a feature for a tutorial: no audio drivers, no dependencies, and the file plays everywhere. (Our web generator exports WAVs the same way, straight from the browser.)

6 The CLI: Make It a Real Tool

Functions are for programmers; a command-line interface is for everyone. argparse turns the whole thing into a proper Unix-style tool:

def main():
    parser = argparse.ArgumentParser(
        description='Encode text to morse code or decode morse code to text.')
    parser.add_argument('text', help='text to encode, or morse code to decode')
    parser.add_argument('--decode', action='store_true',
                        help='decode morse code instead of encoding')
    parser.add_argument('--wav', metavar='FILE',
                        help='write the morse audio to a WAV file')
    parser.add_argument('--wpm', type=float, default=20,
                        help='speed in words per minute (default: 20)')
    args = parser.parse_args()

    if args.decode:
        print(decode(args.text))
    else:
        morse = encode(args.text)
        print(morse)
        if args.wav:
            morse_to_wav(morse, args.wav, wpm=args.wpm)
            print('Wrote', args.wav)


if __name__ == '__main__':
    main()

Save everything as morse_translator.py and try it:

$ python3 morse_translator.py "HELLO WORLD"
.... . .-.. .-.. --- / .-- --- .-. .-.. -..

$ python3 morse_translator.py --decode ".... . .-.. .-.. ---"
HELLO

$ python3 morse_translator.py "<SOS>" --wav sos.wav --wpm 15
...---...
Wrote sos.wav

Play sos.wav in any media player: three short, three long, three short at 15 WPM. You just built a morse code station in ~150 lines.

Where to Go Next

This project is a launchpad, not a finish line. Ideas worth building:

  • Playback: add simpleaudio or pygame to play tones directly instead of writing files.
  • Farnsworth timing: keep characters at full speed but stretch the gaps — the technique our timing guide recommends for learners. Parameterize the gap multiplier.
  • A trainer mode: play random characters, quiz the user, track accuracy — the Koch method in 50 lines. (Or use our web trainer for inspiration.)
  • Click-free audio: add a 3 ms fade envelope at the start/end of each tone to remove the clicks between elements.
  • A web API: wrap encode/decode in Flask or FastAPI and you have a translation microservice.

And if you ever want to check your program's output against a reference implementation, the translator on this site's homepage encodes and decodes with the same ITU table — a handy oracle for your test suite.

Frequently asked questions

Why build a morse translator in Python instead of using a website?

Building it yourself teaches dictionaries, string processing, binary file I/O, and audio synthesis in a single project — and you get an offline tool plus portfolio code. For instant everyday translation, the web translator is still faster.

Why does encode("SOS") give "... --- ..." instead of "...---..."?

Because SOS as text is three letters, and letters are separated by gaps. ...---... with no gaps is the SOS prosign — a single procedural signal. The tutorial handles both: encode("<SOS>") produces the run-together prosign.

How does WPM timing work in the audio code?

One timing unit lasts 1.2 ÷ WPM seconds, calibrated against the standard word PARIS. A dot is 1 unit, a dash is 3, with 1-unit gaps inside letters, 3 between letters, and 7 between words. See the timing guide for the full breakdown.

Can the script play sound directly instead of writing a WAV?

The wave module only writes files — it can't play audio. Play the WAV with any media player, or add a playback library like simpleaudio as an extension. Writing the file keeps the tutorial dependency-free.

How do I handle lowercase or unsupported characters?

The encoder uppercases everything first, and characters with no morse equivalent (like emoji) are skipped instead of crashing. The decoder marks unknown patterns with ? so bad input is visible, never silent.

Can this decode morse audio back to text?

Not this script — decoding audio requires signal processing (detecting tones, measuring timing), which is a much bigger project. If you need that today, our audio decoder does the DSP for you in the browser.

Check your code against the oracle.

Run your script's output through the web translator — same ITU table, instant verification.

Open the translator →

Related articles