Morse Code in Python: From a Three-Line Translator to Real Audio

2026-03-17 · Programming

If C teaches you what Morse code costs at the byte level, Python teaches you what it costs at the thinking level — and the answer is: almost nothing. A complete, correct translator fits in a dictionary and a comprehension. Adding sound is a few lines more. That economy makes Python the ideal language for prototypes, classroom demos, and weekend experiments, and it is no accident that most hobby Morse projects on GitHub are Python.

This walkthrough builds the stack in layers: first the famous three-line version that shows off the language, then a fuller implementation with proper error handling, then real audio playback on Windows and cross-platform, then WAV export. Along the way it flags the handful of Python-specific traps — KeyError on lowercase, integer division in timing math, and the differences between audio libraries.

As ever, ground truth lives in the interactive chart — every code your dictionary produces should match it exactly, and you can listen to any letter to check by ear.

What does the shortest working Morse translator look like?

The viral version of this program really is three statements, and it is worth typing out once to feel how much Python's built-ins are doing — a hash-map lookup for the encoding, a generator expression for the traversal, str.join for assembly, and a dict comprehension to invert the mapping for decoding:

Running encode("hi you") on that dictionary yields '.... .. / -.-- --- ..-.'; feed the result back through decode and the original text returns. Is this the version to ship? No — the silent CODE.get(c, '') swallows every unsupported character without a trace, and cramming logic into lambdas makes errors unreadable. But as a demonstration of why people fall for this language, it is honest: the entire specification fits on a business card.

Compare the same program written in C, where the equivalent functionality spans buffers, index arithmetic, and manual tokenization. Neither is wrong — one makes the machine visible, the other makes the idea visible.

  • CODE = {'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': '----.'}
  • encode = lambda s: ' '.join('/' if c == ' ' else CODE.get(c, '') for c in s.upper())
  • decode = lambda m: ' '.join(''.join({v: k for k, v in CODE.items()}[t] for t in w.split()) for w in m.split(' / '))

How do you build a production-friendly version?

The grown-up version keeps the dictionary — it is the right data structure — but wraps the traversal in a real function with explicit policies for unknown characters and word gaps. Punctuation has official Morse forms too (the comma is --..--, the period .-.-.-), and adding them is just more dictionary entries; the full set is on the punctuation page.

The upgrades that matter beyond the three-liner: return unknown-character counts instead of discarding them silently, accept both / and run-of-spaces as word separators on decode, and expose DOT_MS as a parameter rather than a buried constant so speed can be tuned per context. The Morse speed rule is the same everywhere: dot in milliseconds equals 1200 divided by words per minute, so a single constant calibrates your entire program against the 12–20 WPM range real operators use. Keeping the dictionary at module scope — one dict for encoding, one inverted dict built once for decoding — also turns the module into something you can import cleanly from other scripts rather than re-pasting.

How do you play Morse code audio in Python?

On Windows, audio is genuinely this easy — winsound ships with the standard library and Beep blocks for its duration, which conveniently doubles as your gap generator:

The one gotcha is units: Beep takes milliseconds, time.sleep takes seconds, and mixing them up gives you either an inaudible chirp or a three-minute pause. That DOT / 1000 division is responsible for more confusion than any other line in Morse tutorials.

Cross-platform, the standard answer is NumPy plus simpleaudio: synthesize sine bursts as int16 arrays, concatenate silence between them, and play the whole message as one buffer. The structure maps one-to-one onto the winsound version — tone for on, zero-array for gap — and it has the side benefit that the exact same samples can be written to a WAV file, which is the next section.

  • import time, winsound
  • FREQ, DOT = 750, 80 # Hz, milliseconds (~15 WPM)
  • def play(morse):
  • for ch in morse:
  • if ch == '.': winsound.Beep(FREQ, DOT)
  • elif ch == '-': winsound.Beep(FREQ, 3 * DOT)
  • elif ch == ' ': time.sleep(DOT / 1000)
  • elif ch == '/': time.sleep(7 * DOT / 1000)

How do you save a WAV file instead?

Writing the audio out turns your script into a tool other programs can consume, and Python's wave module makes it four lines once you have the sample array from the simpleaudio approach.

One workflow this unlocks: generate a WAV here, then verify it by ear or feed it to the online audio decoder, which listens to a recording and extracts the text — a satisfying end-to-end loop where your encoder, your ears, and an independent decoder all have to agree. For generating signals in other media, the same timing table drives the light-signal tool visual equivalent.

  • import wave
  • with wave.open('message.wav', 'w') as w:
  • w.setnchannels(1) # mono
  • w.setsampwidth(2) # 16-bit
  • w.setframerate(44100)
  • w.writeframes(samples.tobytes())

Which Python bugs bite Morse projects?

The failure modes are fewer than in C but more surprising when they hit:

None of these are exotic — together they form the standard toll booth between a demo and a tool, and passing through it deliberately before shipping is what makes the difference.

  • `KeyError` on lowercase input. CODE['a'] explodes because the dictionary only holds uppercase keys. .upper() the input first, or use .get with a logged default.
  • Silent character loss. CODE.get(c, '') turns "héllö" into "HLL" with no complaint. Fine for a demo; unacceptable anywhere reliability matters.
  • Millisecond/second confusion. Covered above — the Beep-vs-sleep unit mismatch. Pick one convention and convert at the boundary.
  • Reversed-dict rebuilds in loops. {v: k for k, v in CODE.items()} inside decode rebuilds the inverse table per call. Build it once at module level; Morse codes are unique so the inversion is safe.
  • Assuming winsound exists elsewhere. It is Windows-only. Guard the import and fall back to simpleaudio or print-only mode.
  • Blocking playback in servers. Beep and wait_done block their thread. In a web app or bot, push audio generation to a worker or write WAVs instead.

How do you turn it into a command-line tool?

Once encode and decode work as functions, the standard next move is wrapping them in a CLI — it takes ten lines with the standard library's argparse and instantly makes the project scriptable from shells, cron jobs, and build pipelines:

The auto-detecting line in that listing is a nice Pythonic touch worth stealing: Morse input consists only of dots, dashes, slashes and spaces, so a set comparison distinguishes it from ordinary text without flags. Invoke the tool as python morse.py "sos" -p -w 20 and you have a spec-following, speed-adjustable beeper with a --help screen you did not have to write.

  • import argparse
  • p = argparse.ArgumentParser(description='Morse translator')
  • p.add_argument('text', help='message or Morse to convert')
  • p.add_argument('-p', '--play', action='store_true', help='beep the result')
  • p.add_argument('-w', '--wpm', type=int, default=15, help='speed')
  • a = p.parse_args()
  • out = decode(a.text) if set(a.text) <= set('.-/ ') else encode(a.text)
  • print(out)
  • if a.play: play(out, wpm=a.wpm)

How do you test a Morse module in Python?

Python's round-trip test is even more direct than the C version because random string generation lives in the standard library:

Add golden cases as plain asserts — encode("SOS") == "... --- ..." and decode(".... .. / -.-- --- ..-") == "HI YOU" — and the module is safely refactorable. One subtlety the random test will surface immediately: whether your word separator is ' / ' on both sides of the round trip. Encode with a bare / and no spaces and decode will still work; encode with spaces and a strict split(' / ') will work; mix conventions and the test fails loudly — which is exactly what it is for. Run the suite with pytest or plain python -m pytest, wire it into CI if the module grows, and the dictionary can then be extended to punctuation without fear.

  • import random, string
  • def test_roundtrip():
  • for _ in range(200):
  • s = ''.join(random.choices(string.ascii_uppercase + ' ', k=random.randint(0, 40)))
  • assert decode(encode(s)) == s

What should you build next with it?

The translator core composes into real projects quickly: a CLI that reads stdin and beeps each line; a Flask endpoint that returns WAV bytes for any text — your own private translator API; a "name ringtone" generator that encodes a name like James or Sarah as an audio file; a practice trainer that plays random letters and scores your typed answers against the learn mode curriculum.

For hardware people, the natural next step is moving the same timing logic onto an Arduino, where tone() and delay() replace the audio stack and a physical speaker makes the rhythm tangible. And for the full-circle experience, encode I love you, play it across the room, and see who notices — the message that started the whole jewelry trend is still the best end-to-end test.

Ham Radio Q-Codes Explained: QSO, QTH, QTitanic and the Wireless: The Two Men at