DEV Community

Morse Zone
Morse Zone

Posted on

Decode Morse Code Audio in Python: From Waveform to Text

Decode Morse Code Audio in Python: From Waveform to Text

Morse code is, at its heart, a timing problem. A dot is one unit of tone, a dash is three, and the silences between them carry just as much meaning as the sounds. That makes it a perfect starter project for digital signal processing: you can go from a raw WAV file to readable text in under a hundred lines of Python, and every step teaches a concept that shows up again in real-world audio work.

In this tutorial we will build a complete Morse audio decoder: generate a test signal, extract its envelope, slice it into marks and spaces, classify each slice by duration, and decode the result into text. The only dependency is NumPy.

How Morse timing actually works

Everything in Morse is measured in multiples of one base unit:

  • Dot (dit): 1 unit of tone
  • Dash (dah): 3 units of tone
  • Gap inside a letter: 1 unit of silence
  • Gap between letters: 3 units of silence
  • Gap between words: 7 units of silence

So decoding audio is really a classification task: measure how long each burst of tone lasts, measure how long each silence lasts, and map those durations back to symbols. If you can measure time accurately, you can decode Morse.

Step 1: Build a test signal

Before decoding real audio, generate a known-good test signal. That way you can verify the decoder against ground truth. First, the code table and a text-to-signal function:

import numpy as np

MORSE_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',
}
TEXT_TO_MORSE = {v: k for k, v in MORSE_CODE.items()}


def text_to_signal(text, unit=0.08, freq=700.0, sample_rate=44100):
    """Render TEXT as a Morse tone signal (NumPy array)."""
    out = []

    def tone(dur):
        n = int(sample_rate * dur)
        t = np.arange(n) / sample_rate
        return np.sin(2 * np.pi * freq * t)

    def silence(dur):
        return np.zeros(int(sample_rate * dur))

    words = text.upper().split(' ')
    for wi, word in enumerate(words):
        for li, ch in enumerate(word):
            code = TEXT_TO_MORSE[ch]
            for si, sym in enumerate(code):
                out.append(tone(unit if sym == '.' else 3 * unit))
                if si < len(code) - 1:
                    out.append(silence(unit))       # intra-letter gap
            if li < len(word) - 1:
                out.append(silence(3 * unit))       # letter gap
        if wi < len(words) - 1:
            out.append(silence(7 * unit))           # word gap
    return np.concatenate(out)
Enter fullscreen mode Exit fullscreen mode

text_to_signal("SOS") gives you a clean 700 Hz tone spelling ... --- ... — your ground truth for testing.

Step 2: Envelope detection

Real (and generated) Morse audio is an oscillating tone. We do not care about the oscillation — we care about when the tone is on. The standard trick is envelope detection: rectify the signal (take the absolute value) and smooth it with a moving average. The result is high where tone is present and near zero during silences.

def envelope(sig, sample_rate, smooth=0.01):
    rectified = np.abs(sig)
    k = max(1, int(sample_rate * smooth))   # 10 ms smoothing window
    kernel = np.ones(k) / k
    return np.convolve(rectified, kernel, mode='same')
Enter fullscreen mode Exit fullscreen mode

A 10 ms window is a good default: much shorter than a dot (80 ms at our test speed) so it does not smear symbols together, but long enough to flatten the 700 Hz ripple.

Step 3: Thresholding and run-length encoding

Pick a threshold — say 25% of the envelope's peak — and turn the envelope into a boolean mask: tone on, or tone off. Then compress the mask into runs of consecutive on/off samples. This is run-length encoding, and it converts ~100,000 samples into a few dozen (start, end) pairs:

def find_runs(mask):
    """Yield (start, end) index pairs for each True run in a boolean mask."""
    idx = np.flatnonzero(mask)
    if len(idx) == 0:
        return
    start = prev = idx[0]
    for i in idx[1:]:
        if i == prev + 1:
            prev = i
        else:
            yield start, prev + 1
            start = prev = i
    yield start, prev + 1


def slice_signal(sig, sample_rate):
    env = envelope(sig, sample_rate)
    on = env > env.max() * 0.25
    marks = [(e - s) / sample_rate for s, e in find_runs(on)]
    # Spaces are the gaps *between* marks: invert the mask and
    # drop the leading/trailing silence.
    off_runs = list(find_runs(~on))
    interior = off_runs[1:-1] if len(off_runs) > 2 else []
    spaces = [(e - s) / sample_rate for s, e in interior]
    return marks, spaces
Enter fullscreen mode Exit fullscreen mode

Step 4: Classify by duration and decode

Now the timing table does the work. Estimate the base unit from the shortest mark (almost every real message contains at least one dot), then round each duration to the nearest multiple:

def decode_signal(sig, sr):
    marks, spaces = slice_signal(sig, sr)
    if not marks:
        return ''
    unit = min(marks)  # shortest mark = one dot

    def nearest(duration, choices):
        return min(choices, key=lambda c: abs(duration / unit - c))

    text, current = '', ''
    for i, m in enumerate(marks):
        current += '.' if nearest(m, (1, 3)) == 1 else '-'
        if i < len(spaces):
            gap = nearest(spaces[i], (1, 3, 7))
            if gap == 3:
                text += MORSE_CODE.get(current, '?')
                current = ''
            elif gap == 7:
                text += MORSE_CODE.get(current, '?') + ' '
                current = ''
    text += MORSE_CODE.get(current, '?')
    return text.strip()
Enter fullscreen mode Exit fullscreen mode

Putting it together

if __name__ == '__main__':
    sr = 44100
    signal = text_to_signal('SOS MORSE', unit=0.08, sample_rate=sr)
    # Add a little noise to prove the point:
    signal = signal + np.random.normal(0, 0.05, signal.shape)
    print(decode_signal(signal, sr))   # -> SOS MORSE
Enter fullscreen mode Exit fullscreen mode

Run it and you get SOS MORSE back — the full round trip from text, to waveform, to text again. Try changing the unit to simulate different sending speeds, or raise the noise floor to find where the fixed 25% threshold starts to break. (Spoiler: adaptive thresholds and median-based unit estimation are the natural next steps.)

Making it robust for real audio

The toy version above assumes clean audio and a known-ish speed. Real recordings need a few upgrades:

  • Unknown speed: estimate the unit from a histogram of mark durations instead of a single minimum — the two clear clusters (dots vs dashes) reveal it.
  • Noise: a fixed fraction-of-peak threshold fails when background hum is loud. Use a noise-floor estimate from the quietest 10% of the envelope instead.
  • Fading signals: ham radio signals fade in and out. Normalise the envelope in sliding windows rather than globally.
  • Human sending: hand-sent Morse has wobbly timing. Widen the classification tolerance and prefer the ratio between consecutive marks over absolute durations.

Each of these is a small, satisfying change — and each one mirrors a technique used in production audio pipelines, from voice activity detection to music onset detection.

Try it on real Morse audio

Once your decoder works on generated signals, feed it the real thing: record the audio playback from an online Morse code translator spelling your name, load the WAV with scipy.io.wavfile or the soundfile package, and decode it. Hearing your own name come back out of a waveform you sliced up yourself is the moment the DSP concepts click.

Morse code survived 180 years because it is beautifully efficient — the most common letters got the shortest codes, a Huffman-style optimisation decades before Huffman. Decoding it in Python takes an afternoon and teaches envelope detection, thresholding, and run-length encoding along the way. Not bad for a 19th-century protocol.


Morse Zone (morsezone.com) is a free online Morse code translator with audio playback, timing visualisations, and learning tools — handy for generating test signals for projects like this one.

Top comments (0)